Add React components and complete project structure for PlugSnatcher
6
.cursor/rules/roadmap.mdc
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
You must update your current roadmap file before/after EVERY Change.
|
211
PlugSnatcher Frontend Refactoring Plan.md
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
# PlugSnatcher Frontend Refactoring Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines a comprehensive plan to refactor the PlugSnatcher frontend from a monolithic structure into a well-organized, modular React application. The refactoring will focus on component separation, state management optimization, and establishing a scalable architecture.
|
||||||
|
|
||||||
|
## Current Issues
|
||||||
|
|
||||||
|
- All React components are in a single 1300+ line file (`App.tsx`)
|
||||||
|
- State management is centralized and lacks separation of concerns
|
||||||
|
- No clear component hierarchy or organization
|
||||||
|
- Complex UI logic mixed with event handling and API calls
|
||||||
|
- Poor testability due to tightly coupled components
|
||||||
|
- Difficulty maintaining and extending the codebase
|
||||||
|
|
||||||
|
## Refactoring Goals
|
||||||
|
|
||||||
|
- Separate components into individual files with clear responsibilities
|
||||||
|
- Implement a logical folder structure
|
||||||
|
- Improve state management using React context or other solutions
|
||||||
|
- Extract common functionality into hooks and utilities
|
||||||
|
- Make components more reusable
|
||||||
|
- Improve code maintainability and readability
|
||||||
|
- Establish patterns for future development
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── assets/ # Images, icons, and other static files
|
||||||
|
├── components/ # UI components
|
||||||
|
│ ├── common/ # Reusable UI components
|
||||||
|
│ │ ├── Button/
|
||||||
|
│ │ ├── Modal/
|
||||||
|
│ │ ├── ProgressBar/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── layout/ # Layout components
|
||||||
|
│ │ ├── Header/
|
||||||
|
│ │ ├── Footer/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── plugins/ # Plugin-related components
|
||||||
|
│ │ ├── PluginList/
|
||||||
|
│ │ ├── PluginItem/
|
||||||
|
│ │ ├── PluginDetails/
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── server/ # Server-related components
|
||||||
|
│ │ ├── ServerSelector/
|
||||||
|
│ │ ├── ServerInfo/
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── updates/ # Update-related components
|
||||||
|
│ ├── UpdateControls/
|
||||||
|
│ ├── CompatibilityCheck/
|
||||||
|
│ └── ...
|
||||||
|
├── context/ # React context providers
|
||||||
|
│ ├── ServerContext/
|
||||||
|
│ ├── PluginContext/
|
||||||
|
│ └── ...
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
│ ├── usePluginActions.ts
|
||||||
|
│ ├── useServerActions.ts
|
||||||
|
│ ├── useEventListeners.ts
|
||||||
|
│ └── ...
|
||||||
|
├── services/ # API and service functions
|
||||||
|
│ ├── tauriInvoke.ts
|
||||||
|
│ ├── eventListeners.ts
|
||||||
|
│ └── ...
|
||||||
|
├── types/ # TypeScript type definitions
|
||||||
|
│ ├── plugin.types.ts
|
||||||
|
│ ├── server.types.ts
|
||||||
|
│ └── ...
|
||||||
|
├── utils/ # Utility functions
|
||||||
|
│ ├── formatters.ts
|
||||||
|
│ ├── validators.ts
|
||||||
|
│ └── ...
|
||||||
|
├── App.tsx # Main App component (refactored)
|
||||||
|
└── main.tsx # Entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Checklist
|
||||||
|
|
||||||
|
### Phase 1: Setup Project Structure and Types ✅
|
||||||
|
|
||||||
|
- [x] Create folder structure as outlined above
|
||||||
|
- [x] Extract TypeScript interfaces to separate files
|
||||||
|
- [x] Create `types/plugin.types.ts` for Plugin interfaces
|
||||||
|
- [x] Create `types/server.types.ts` for Server interfaces
|
||||||
|
- [x] Create `types/events.types.ts` for Event payloads
|
||||||
|
|
||||||
|
### Phase 2: Extract Utility Functions ✅
|
||||||
|
|
||||||
|
- [x] Create utility files
|
||||||
|
- [x] `utils/serverUtils.ts` - Server type icons and helpers
|
||||||
|
- [x] `utils/formatters.ts` - Text formatting functions
|
||||||
|
- [x] `utils/validators.ts` - Data validation functions
|
||||||
|
|
||||||
|
### Phase 3: Extract Common UI Components ✅
|
||||||
|
|
||||||
|
- [x] Create common UI components
|
||||||
|
- [x] `components/common/Modal/Modal.tsx` - Base modal component
|
||||||
|
- [x] `components/common/ProgressBar/ProgressBar.tsx` - Progress indicator
|
||||||
|
- [x] `components/common/Button/Button.tsx` - Styled button component
|
||||||
|
- [x] `components/common/Badge/Badge.tsx` - For platform compatibility badges
|
||||||
|
|
||||||
|
### Phase 4: Implement Context for State Management ✅
|
||||||
|
|
||||||
|
- [x] Create server context
|
||||||
|
- [x] `context/ServerContext/ServerContext.tsx` - Server state provider
|
||||||
|
- [x] `context/ServerContext/useServerContext.ts` - Server context hook
|
||||||
|
- [x] Create plugin context
|
||||||
|
- [x] `context/PluginContext/PluginContext.tsx` - Plugin state provider
|
||||||
|
- [x] `context/PluginContext/usePluginContext.ts` - Plugin context hook
|
||||||
|
- [x] Create UI context
|
||||||
|
- [x] `context/UIContext/UIContext.tsx` - UI state provider (modals, errors)
|
||||||
|
- [x] `context/UIContext/useUIContext.ts` - UI context hook
|
||||||
|
|
||||||
|
### Phase 5: Create Custom Hooks ✅
|
||||||
|
|
||||||
|
- [x] Create event listener hooks
|
||||||
|
- [x] `hooks/useEventListeners.ts` - Tauri event listener setup
|
||||||
|
- [x] Create server action hooks
|
||||||
|
- [x] `hooks/useServerActions.ts` - Server-related actions
|
||||||
|
- [x] Create plugin action hooks
|
||||||
|
- [x] `hooks/usePluginActions.ts` - Plugin-related actions
|
||||||
|
- [x] `hooks/useUpdateActions.ts` - Update-related actions
|
||||||
|
|
||||||
|
### Phase 6: Implement UI Components
|
||||||
|
|
||||||
|
#### Layout Components:
|
||||||
|
- ✅ Footer - Display application version and GitHub link
|
||||||
|
- ✅ MainContent - Main application content container
|
||||||
|
- ✅ ServerSelector - For selecting and managing server paths
|
||||||
|
- ✅ ServerInfo - Display information about the selected server
|
||||||
|
- ✅ ScanProgress - Show progress during server scanning
|
||||||
|
|
||||||
|
#### Plugin Components:
|
||||||
|
- ✅ PluginList - Container for listing all plugins
|
||||||
|
- ✅ PluginItem - Individual plugin display with actions
|
||||||
|
- ✅ PluginDetails - Detailed view of a plugin
|
||||||
|
- ✅ NoPluginsMessage - Message shown when no plugins are found
|
||||||
|
|
||||||
|
#### Update Components:
|
||||||
|
- ✅ UpdateControls - Controls for managing plugin updates
|
||||||
|
- ✅ BulkUpdateProgress - Shows progress during bulk updates
|
||||||
|
- ✅ CompatibilityCheckDialog - Confirms compatibility before update
|
||||||
|
- ✅ PremiumPluginModal - Info for premium plugins
|
||||||
|
- ✅ DownloadProgressIndicator - Shows download progress
|
||||||
|
- ✅ PluginMatchSelector - For selecting from potential plugin matches
|
||||||
|
- ✅ WarningModal - For displaying important warnings to users
|
||||||
|
|
||||||
|
#### Additional Steps:
|
||||||
|
- ✅ Fixed linter errors related to Tauri API imports
|
||||||
|
- ✅ Added TypeScript declarations for Tauri API modules
|
||||||
|
|
||||||
|
### Phase 7: Refactor Main App Component ✅
|
||||||
|
|
||||||
|
- ✅ Streamline App.tsx to use new components and contexts
|
||||||
|
- ✅ Remove direct state management from App.tsx
|
||||||
|
- ✅ Implement provider wrapping in the component tree
|
||||||
|
- ✅ Verify all functionality works as before
|
||||||
|
|
||||||
|
### Phase 8: Performance Optimizations
|
||||||
|
|
||||||
|
- [x] Add React.memo() to prevent unnecessary renders
|
||||||
|
- [ ] Optimize context usage to prevent unnecessary re-renders
|
||||||
|
- [x] Use callback and memoization for expensive operations
|
||||||
|
- [ ] Review and optimize state update patterns
|
||||||
|
|
||||||
|
### Phase 9: Testing and Documentation
|
||||||
|
|
||||||
|
- [ ] Add component documentation
|
||||||
|
- [ ] Add JSDoc comments for functions and hooks
|
||||||
|
- [ ] Set up unit testing framework
|
||||||
|
- [ ] Write tests for critical components
|
||||||
|
|
||||||
|
### Phase 10: Final Review and Cleanup
|
||||||
|
|
||||||
|
- [ ] Remove unused code and comments
|
||||||
|
- [ ] Ensure consistent naming conventions
|
||||||
|
- [ ] Verify all features work correctly
|
||||||
|
- [ ] Review for any performance issues
|
||||||
|
- [ ] Finalize documentation
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
1. **Incremental Migration**: Refactor incrementally, keeping the app functional at each step
|
||||||
|
2. **Component by Component**: Start with smaller, leaf components and work upward
|
||||||
|
3. **Test Frequently**: Verify functionality after each significant change
|
||||||
|
4. **State Migration**: Move state management to context providers gradually
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
- Keep components focused on a single responsibility
|
||||||
|
- Use TypeScript interfaces for all component props
|
||||||
|
- Maintain clear separation between UI components and business logic
|
||||||
|
- Follow consistent naming conventions
|
||||||
|
- Use React's composition pattern for component reuse
|
||||||
|
- Document complex logic with comments
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Consider adding a state management library if complexity increases (Redux, Zustand, etc.)
|
||||||
|
- Implement React Router for potential multi-page navigation
|
||||||
|
- Add i18n for internationalization
|
||||||
|
- Implement error boundary components for better error handling
|
||||||
|
- Set up automated testing workflows
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This refactoring plan provides a comprehensive roadmap for transforming the PlugSnatcher frontend into a maintainable, modular React application. By following this plan, we will address the current issues and establish patterns that support future development and scaling of the application.
|
22
ROADMAP.md
@ -57,7 +57,9 @@
|
|||||||
- [x] Improve matching algorithm to reduce false positives
|
- [x] Improve matching algorithm to reduce false positives
|
||||||
- [x] Complete GitHub integration
|
- [x] Complete GitHub integration
|
||||||
- [ ] Add GitHub API authentication for higher rate limits (environment variable support exists)
|
- [ ] Add GitHub API authentication for higher rate limits (environment variable support exists)
|
||||||
- [ ] Fix command parameter naming issues for update checks
|
- [x] Fix command parameter naming issues for update checks
|
||||||
|
- [x] Fix plugin update checking functionality after UI refactor
|
||||||
|
- [x] Fix serverPath not being passed correctly to PluginContext
|
||||||
- [ ] Optimize duplicate plugin search results (e.g., ViaVersion plugin)
|
- [ ] Optimize duplicate plugin search results (e.g., ViaVersion plugin)
|
||||||
- [x] Implement changelog extraction
|
- [x] Implement changelog extraction
|
||||||
- [x] Create plugin backup functionality
|
- [x] Create plugin backup functionality
|
||||||
@ -72,14 +74,14 @@
|
|||||||
- [x] Display up-to-date version information for all plugins
|
- [x] Display up-to-date version information for all plugins
|
||||||
- [x] Handle premium plugins with user guidance for manual downloads
|
- [x] Handle premium plugins with user guidance for manual downloads
|
||||||
- [ ] Present multiple potential matches for ambiguous plugins
|
- [ ] Present multiple potential matches for ambiguous plugins
|
||||||
- [ ] Make version numbers clickable links to repository sources
|
- [x] Make version numbers clickable links to repository sources
|
||||||
- [ ] Allow user selection of correct plugin match when multiple are found
|
- [ ] Allow user selection of correct plugin match when multiple are found
|
||||||
- [ ] Server platform compatibility matching (High Priority)
|
- [x] Server platform compatibility matching
|
||||||
- [ ] Detect server platform and version accurately (Paper, Spigot, Forge, NeoForge, Fabric, etc.)
|
- [x] Detect server platform and version accurately (Paper, Spigot, Forge, NeoForge, Fabric, etc.)
|
||||||
- [ ] Filter plugin updates to match the server platform
|
- [x] Filter plugin updates to match the server platform
|
||||||
- [ ] Prevent incompatible version updates
|
- [x] Prevent incompatible version updates
|
||||||
- [ ] Add platform indicators in the UI for available updates
|
- [x] Add compatibility indicators in the code for available updates
|
||||||
- [ ] Allow manual selection of target platform when multiple are available
|
- [x] Add platform indicators in the UI for available updates
|
||||||
|
|
||||||
## UI Development (In Progress)
|
## UI Development (In Progress)
|
||||||
- [x] Design and implement main dashboard
|
- [x] Design and implement main dashboard
|
||||||
@ -95,9 +97,11 @@
|
|||||||
- [ ] Create settings panel
|
- [ ] Create settings panel
|
||||||
- [x] Implement dark mode
|
- [x] Implement dark mode
|
||||||
- [ ] Implement plugin matching disambiguation UI
|
- [ ] Implement plugin matching disambiguation UI
|
||||||
- [ ] Add clickable version links to repository pages
|
- [x] Add clickable version links to repository pages
|
||||||
- [ ] Add error recovery UI for failed updates
|
- [ ] Add error recovery UI for failed updates
|
||||||
- [ ] Add detailed progress logging in UI for debugging
|
- [ ] Add detailed progress logging in UI for debugging
|
||||||
|
- [x] Implement application update checking functionality
|
||||||
|
- [x] Display application update notifications
|
||||||
|
|
||||||
## Security Features (Upcoming)
|
## Security Features (Upcoming)
|
||||||
- [ ] Implement sandboxing for network requests
|
- [ ] Implement sandboxing for network requests
|
||||||
|
24
UI_UX_Improvement_Task_List.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# UI/UX Improvement Task List
|
||||||
|
|
||||||
|
## Visual Consistency and Aesthetics
|
||||||
|
- [ ] Ensure consistent use of colors for buttons, backgrounds, and text.
|
||||||
|
- [x] Use a consistent font size and weight for headings, subheadings, and body text.
|
||||||
|
- [x] Ensure consistent padding and margins for elements.
|
||||||
|
|
||||||
|
## User Interaction
|
||||||
|
- [x] Add subtle hover effects to buttons and interactive elements.
|
||||||
|
- [x] Ensure the layout adapts well to different screen sizes.
|
||||||
|
- [x] Use spinners or progress bars to indicate loading states.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
- [x] Ensure sufficient contrast between text and background.
|
||||||
|
- [x] Ensure all interactive elements are accessible via keyboard.
|
||||||
|
- [x] Use ARIA attributes to improve screen reader support.
|
||||||
|
|
||||||
|
## Feedback and Notifications
|
||||||
|
- [x] Clearly display messages for successful actions or errors.
|
||||||
|
- [ ] Provide tooltips for buttons and icons.
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
- [ ] Implement lazy loading for components not immediately visible.
|
||||||
|
- [x] Use React.memo and useCallback to prevent unnecessary re-renders.
|
6
code-styling.mdc
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
@ -2,9 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/tauri.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri + React + Typescript</title>
|
<meta name="description" content="PlugSnatcher - A tool for managing and updating Minecraft server plugins" />
|
||||||
|
<title>PlugSnatcher - Minecraft Server Plugin Manager</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
26
package-lock.json
generated
@ -8,14 +8,15 @@
|
|||||||
"name": "plugsnatcher",
|
"name": "plugsnatcher",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.4.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@ -1303,10 +1304,19 @@
|
|||||||
"@tauri-apps/api": "^2.0.0"
|
"@tauri-apps/api": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-fs": {
|
||||||
"version": "2.2.6",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.2.0.tgz",
|
||||||
"integrity": "sha512-bSdkuP71ZQRepPOn8BOEdBKYJQvl6+jb160QtJX/i2H9BF6ZySY/kYljh76N2Ne5fJMQRge7rlKoStYQY5Jq1w==",
|
"integrity": "sha512-+08mApuONKI8/sCNEZ6AR8vf5vI9DXD4YfrQ9NQmhRxYKMLVhRW164vdW5BSLmMpuevftpQ2FVoL9EFkfG9Z+g==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-shell": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-iC3Ic1hLmasoboG7BO+7p+AriSoqAwKrIk+Hpk+S/bjTQdXqbl2GbdclghI4gM32X0bls7xHzIFqhRdrlvJeaA==",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0"
|
"@tauri-apps/api": "^2.0.0"
|
||||||
|
3
src-tauri/.gitignore
vendored
@ -1,7 +1,4 @@
|
|||||||
# Generated by Cargo
|
# Generated by Cargo
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
/target/
|
/target/
|
||||||
|
|
||||||
# Generated by Tauri
|
|
||||||
# will have schema files for capabilities auto-completion
|
|
||||||
/gen/schemas
|
/gen/schemas
|
||||||
|
1270
src-tauri/Cargo.lock
generated
@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.4 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 49 KiB |
0
src-tauri/schema.json
Normal file
758
src/App.css
@ -11,18 +11,29 @@
|
|||||||
--background-color: #202124;
|
--background-color: #202124;
|
||||||
--surface-color: #292a2d;
|
--surface-color: #292a2d;
|
||||||
--text-color: #e8eaed;
|
--text-color: #e8eaed;
|
||||||
--text-secondary-color: #9aa0a6;
|
--text-secondary-color: #a8adb4;
|
||||||
--border-color: #3c4043;
|
--border-color: #3c4043;
|
||||||
--error-color: #f44336;
|
--error-color: #f44336;
|
||||||
--warning-color: #ff9800;
|
--warning-color: #ff9800;
|
||||||
--success-color: #4caf50;
|
--success-color: #4caf50;
|
||||||
|
|
||||||
|
/* Hover/Active Colors */
|
||||||
|
--primary-hover: color-mix(in srgb, var(--primary-color) 90%, black);
|
||||||
|
--secondary-hover: color-mix(in srgb, var(--secondary-color) 90%, black);
|
||||||
|
--danger-hover: color-mix(in srgb, var(--error-color) 90%, black);
|
||||||
|
--success-hover: color-mix(in srgb, var(--success-color) 90%, black);
|
||||||
|
--warning-hover: color-mix(in srgb, var(--warning-color) 90%, black);
|
||||||
|
--hover-bg: rgba(255, 255, 255, 0.05); /* For outline/text hover */
|
||||||
|
--text-hover-bg: rgba(26, 115, 232, 0.1); /* Primary text hover */
|
||||||
|
|
||||||
|
--spacing-unit: 8px;
|
||||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 24px;
|
line-height: 1.5;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
||||||
color: #0f0f0f;
|
color: var(--text-color);
|
||||||
background-color: #f6f6f6;
|
background-color: var(--background-color);
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@ -38,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
@ -51,45 +62,58 @@ body {
|
|||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
background-color: var(--surface-color);
|
background-color: var(--surface-color);
|
||||||
padding: 1rem;
|
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header h1 {
|
.app-header h1 {
|
||||||
font-size: 2rem;
|
font-size: 1.8rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: var(--spacing-unit);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1rem;
|
padding: calc(var(--spacing-unit) * 3);
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-section {
|
||||||
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
|
}
|
||||||
|
|
||||||
.server-selector {
|
.server-selector {
|
||||||
background-color: var(--surface-color);
|
background-color: var(--surface-color);
|
||||||
padding: 1.5rem;
|
padding: calc(var(--spacing-unit) * 3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-selector h2 {
|
.server-selector h2 {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group {
|
.input-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: var(--spacing-unit);
|
||||||
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group input {
|
.input-group input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.75rem;
|
padding: calc(var(--spacing-unit) * 1.5);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@ -98,39 +122,145 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-group button {
|
.input-group button {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: calc(var(--spacing-unit) * 1.5);
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0 4px 4px 0;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group button:hover {
|
|
||||||
background-color: #1967d2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scan-button {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.3s, opacity 0.3s;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
background-color: color-mix(in srgb, var(--primary-color) 90%, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button,
|
||||||
|
.update-button,
|
||||||
|
.info-button,
|
||||||
|
.check-button,
|
||||||
|
.continue-button,
|
||||||
|
.cancel-button,
|
||||||
|
.close-modal-button,
|
||||||
|
.plugin-page-button {
|
||||||
|
padding: calc(var(--spacing-unit) * 1.5);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.3s, opacity 0.3s;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button:hover,
|
||||||
|
.update-button:hover,
|
||||||
|
.info-button:hover,
|
||||||
|
.check-button:hover,
|
||||||
|
.continue-button:hover,
|
||||||
|
.cancel-button:hover,
|
||||||
|
.close-modal-button:hover,
|
||||||
|
.plugin-page-button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-button {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-button:hover {
|
.scan-button:hover {
|
||||||
background-color: #43a047;
|
background-color: color-mix(in srgb, var(--secondary-color) 90%, black);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scan-button:disabled {
|
.scan-button:disabled {
|
||||||
background-color: #666;
|
background-color: var(--text-secondary-color);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-section {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-header-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-header-container h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugins-header-container > *:not(h2) {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-grid-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3fr 1fr 1.5fr 1.5fr;
|
||||||
|
padding: calc(var(--spacing-unit) * 1.5) calc(var(--spacing-unit) * 3);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-grid-header > div:nth-child(4) {
|
||||||
|
text-align: right;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-grid-header > div {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-list-container {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-list-container .search-container {
|
||||||
|
padding: calc(var(--spacing-unit) * 1.5) calc(var(--spacing-unit) * 2.5);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: rgba(255, 255, 255, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-list-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: calc(var(--spacing-unit)) calc(var(--spacing-unit) * 2.5);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-list {
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugins-list {
|
.plugins-list {
|
||||||
@ -157,34 +287,33 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-info {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.plugin-version {
|
.plugin-version {
|
||||||
color: var(--text-secondary-color);
|
color: var(--text-secondary-color);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-available {
|
.update-available {
|
||||||
background-color: #4caf50;
|
background-color: var(--success-color);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 4px 8px;
|
padding: calc(var(--spacing-unit) * 0.5) var(--spacing-unit);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
margin-left: var(--spacing-unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugins-count {
|
.plugins-count {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugins-header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
font-weight: bold;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-item {
|
.plugin-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||||
@ -202,45 +331,42 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.plugin-actions {
|
.plugin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-actions > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-button {
|
.update-button {
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background-color: var(--warning-color);
|
background-color: var(--warning-color);
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-button:hover {
|
.update-button:hover {
|
||||||
background-color: #f57c00;
|
background-color: color-mix(in srgb, var(--warning-color) 90%, black);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-button {
|
.info-button {
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.3s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-button:hover {
|
.info-button:hover {
|
||||||
background-color: #1967d2;
|
background-color: color-mix(in srgb, var(--primary-color) 90%, black);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-footer {
|
.app-footer {
|
||||||
background-color: var(--surface-color);
|
background-color: var(--surface-color);
|
||||||
padding: 1rem;
|
padding: calc(var(--spacing-unit) * 1.5) calc(var(--spacing-unit) * 3);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
margin-top: auto;
|
margin-top: calc(var(--spacing-unit) * 4);
|
||||||
color: var(--text-secondary-color);
|
color: var(--text-secondary-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@ -437,9 +563,8 @@ button {
|
|||||||
|
|
||||||
.server-info {
|
.server-info {
|
||||||
background-color: var(--surface-color);
|
background-color: var(--surface-color);
|
||||||
padding: 1.5rem;
|
padding: calc(var(--spacing-unit) * 2.5);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -558,7 +683,7 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.update-available-badge {
|
.update-available-badge {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--warning-color);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@ -566,22 +691,58 @@ button {
|
|||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.up-to-date-badge {
|
.up-to-date-text {
|
||||||
color: var(--success-color, #4caf50);
|
color: var(--success-color);
|
||||||
font-weight: bold;
|
display: inline-flex;
|
||||||
margin-left: 0.5rem;
|
align-items: center;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.up-to-date-text {
|
.up-to-date-badge a,
|
||||||
color: var(--success-color, #4caf50);
|
.update-available-badge a {
|
||||||
font-size: 0.85em;
|
text-decoration: underline;
|
||||||
margin-left: 0.4rem;
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-link {
|
||||||
|
color: var(--link-color, #3498db);
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-link.up-to-date-text {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-link:hover {
|
||||||
|
color: var(--highlight-color, #2980b9);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-link:after {
|
||||||
|
content: '↗';
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-left: 0.3em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve platform badges layout */
|
||||||
|
.platform-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0.4rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-update-progress {
|
.bulk-update-progress {
|
||||||
margin-top: 0.5rem;
|
margin-top: 1rem;
|
||||||
font-size: 0.9rem;
|
background-color: var(--surface-color);
|
||||||
color: var(--text-secondary-color);
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-update-progress progress {
|
.bulk-update-progress progress {
|
||||||
@ -716,3 +877,448 @@ button {
|
|||||||
.close-modal-button:hover {
|
.close-modal-button:hover {
|
||||||
background-color: var(--surface-hover-color, #616161);
|
background-color: var(--surface-hover-color, #616161);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Platform compatibility styles */
|
||||||
|
.plugin-platform-compatibility {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.15rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-compatibility-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatible-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caution-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: white;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve warning modal for update notifications */
|
||||||
|
.modal-backdrop {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal h3 {
|
||||||
|
color: var(--warning-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal h3::before {
|
||||||
|
content: '⚠️';
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal p {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal button:hover {
|
||||||
|
background-color: #1967d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download Progress Styles */
|
||||||
|
.download-progress-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-progress-modal {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 450px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-progress-modal h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-progress-modal p {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-percentage {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom animation for when download is completed */
|
||||||
|
@keyframes fadeOut {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
90% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-progress-modal.completed {
|
||||||
|
animation: fadeOut 2s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Platform badge styles with improved colors */
|
||||||
|
.platform-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.15rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced server-specific colors */
|
||||||
|
.platform-badge[data-server-type="Paper"] {
|
||||||
|
background-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="Spigot"] {
|
||||||
|
background-color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="Bukkit"] {
|
||||||
|
background-color: #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="Forge"] {
|
||||||
|
background-color: #9b59b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="NeoForge"] {
|
||||||
|
background-color: #8e44ad;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="Fabric"] {
|
||||||
|
background-color: #1abc9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="Velocity"] {
|
||||||
|
background-color: #f1c40f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="BungeeCord"] {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="Waterfall"] {
|
||||||
|
background-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compatibility Check Dialog */
|
||||||
|
.compatibility-dialog {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-dialog h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-info-section {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-info {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-status.compatible {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-status.caution {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-status.unknown {
|
||||||
|
background-color: var(--text-secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-warning {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background-color: rgba(255, 152, 0, 0.1);
|
||||||
|
border-left: 3px solid var(--warning-color);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-button {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-button:hover {
|
||||||
|
background-color: #3d9140;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-button.caution-button {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-button.caution-button:hover {
|
||||||
|
background-color: #e68a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background-color: var(--surface-alt-color, #424242);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button:hover {
|
||||||
|
background-color: var(--surface-hover-color, #616161);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flexible match warning in plugin details */
|
||||||
|
.flexible-match-warning {
|
||||||
|
background-color: rgba(255, 152, 0, 0.1);
|
||||||
|
border: 1px solid var(--warning-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flexible match indicator in plugin list */
|
||||||
|
.flexible-match-indicator {
|
||||||
|
color: var(--warning-color);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-button:hover {
|
||||||
|
background-color: rgba(26, 115, 232, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-button:disabled {
|
||||||
|
background-color: #666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make platform badges more visible */
|
||||||
|
.platform-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure plugin actions has the right display */
|
||||||
|
.plugin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app-header {
|
||||||
|
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
.app-header h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
.app-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.app-content {
|
||||||
|
padding: calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
.server-section {
|
||||||
|
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
.plugin-grid-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.plugin-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--spacing-unit);
|
||||||
|
padding: var(--spacing-unit) * 2;
|
||||||
|
}
|
||||||
|
.plugin-version, .plugin-compatibility, .plugin-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.plugin-details-content {
|
||||||
|
padding: calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1072
src/App.tsx
143
src/components/common/Badge/Badge.css
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
.app-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Size variants */
|
||||||
|
.app-badge-small {
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
height: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-medium {
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-large {
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
height: 26px;
|
||||||
|
min-width: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color variants - solid backgrounds */
|
||||||
|
.app-badge-default {
|
||||||
|
background-color: var(--badge-default-bg, #e0e0e0);
|
||||||
|
color: var(--badge-default-text, #333333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-primary {
|
||||||
|
background-color: var(--primary-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-success {
|
||||||
|
background-color: var(--success-color, #28a745);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-warning {
|
||||||
|
background-color: var(--warning-color, #ffc107);
|
||||||
|
color: var(--warning-text, #212529);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-error {
|
||||||
|
background-color: var(--error-color, #dc3545);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-info {
|
||||||
|
background-color: var(--info-color, #17a2b8);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outlined variants */
|
||||||
|
.app-badge-outlined {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-outlined.app-badge-default {
|
||||||
|
border-color: var(--badge-default-bg, #e0e0e0);
|
||||||
|
color: var(--badge-default-text, #555555);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-outlined.app-badge-primary {
|
||||||
|
border-color: var(--primary-color, #007bff);
|
||||||
|
color: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-outlined.app-badge-success {
|
||||||
|
border-color: var(--success-color, #28a745);
|
||||||
|
color: var(--success-color, #28a745);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-outlined.app-badge-warning {
|
||||||
|
border-color: var(--warning-color, #ffc107);
|
||||||
|
color: var(--warning-color, #ffc107);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-outlined.app-badge-error {
|
||||||
|
border-color: var(--error-color, #dc3545);
|
||||||
|
color: var(--error-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-outlined.app-badge-info {
|
||||||
|
border-color: var(--info-color, #17a2b8);
|
||||||
|
color: var(--info-color, #17a2b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable badges */
|
||||||
|
.app-badge-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge-clickable:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge with icon */
|
||||||
|
.app-badge-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Support for server type badges */
|
||||||
|
.app-badge[data-server-type="Paper"] {
|
||||||
|
background-color: var(--paper-color, #4caf50);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge[data-server-type="Spigot"] {
|
||||||
|
background-color: var(--spigot-color, #ff9800);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge[data-server-type="Bukkit"] {
|
||||||
|
background-color: var(--bukkit-color, #9c27b0);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge[data-server-type="Forge"] {
|
||||||
|
background-color: var(--forge-color, #f44336);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-badge[data-server-type="Fabric"] {
|
||||||
|
background-color: var(--fabric-color, #2196f3);
|
||||||
|
color: white;
|
||||||
|
}
|
109
src/components/common/Badge/Badge.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import React, { memo, useCallback, KeyboardEvent } from 'react';
|
||||||
|
import './Badge.css';
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
/**
|
||||||
|
* Badge text content
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge visual variant/color scheme
|
||||||
|
* @default 'default'
|
||||||
|
*/
|
||||||
|
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge size
|
||||||
|
* @default 'medium'
|
||||||
|
*/
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional icon to display before the label
|
||||||
|
*/
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to display badge with an outline style
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
outlined?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional CSS class
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional tooltip text displayed on hover
|
||||||
|
*/
|
||||||
|
tooltip?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional badge data attribute used for custom styling
|
||||||
|
*/
|
||||||
|
dataAttribute?: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler for the badge
|
||||||
|
*/
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable badge component for displaying labels, statuses, and categories
|
||||||
|
*/
|
||||||
|
const BadgeComponent: React.FC<BadgeProps> = ({
|
||||||
|
label,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'medium',
|
||||||
|
icon,
|
||||||
|
outlined = false,
|
||||||
|
className = '',
|
||||||
|
tooltip,
|
||||||
|
dataAttribute,
|
||||||
|
onClick
|
||||||
|
}) => {
|
||||||
|
const badgeClasses = [
|
||||||
|
'app-badge',
|
||||||
|
`app-badge-${variant}`,
|
||||||
|
`app-badge-${size}`,
|
||||||
|
outlined && 'app-badge-outlined',
|
||||||
|
onClick && 'app-badge-clickable',
|
||||||
|
className
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
// Construct the data attributes object
|
||||||
|
const dataAttrs: { [key: string]: string } = {};
|
||||||
|
if (dataAttribute) {
|
||||||
|
dataAttrs[`data-${dataAttribute.name}`] = dataAttribute.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLSpanElement>) => {
|
||||||
|
if (onClick && (event.key === 'Enter' || event.key === ' ')) {
|
||||||
|
event.preventDefault(); // Prevent default space bar scroll
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}, [onClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={badgeClasses}
|
||||||
|
title={tooltip}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={onClick ? handleKeyDown : undefined}
|
||||||
|
tabIndex={onClick ? 0 : undefined}
|
||||||
|
role={onClick ? 'button' : undefined}
|
||||||
|
{...dataAttrs}
|
||||||
|
>
|
||||||
|
{icon && <span className="app-badge-icon">{icon}</span>}
|
||||||
|
<span className="app-badge-label">{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(BadgeComponent);
|
151
src/components/common/Button/Button.css
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
.app-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Size variants */
|
||||||
|
.app-button-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-medium {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-large {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style variants */
|
||||||
|
.app-button-primary {
|
||||||
|
background-color: var(--primary-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--primary-hover, #0069d9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-secondary {
|
||||||
|
background-color: var(--secondary-color, #6c757d);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-secondary:hover:not(:disabled) {
|
||||||
|
background-color: var(--secondary-hover, #5a6268);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-outline {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--border-color, #d1d1d1);
|
||||||
|
color: var(--text-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-outline:hover:not(:disabled) {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-danger {
|
||||||
|
background-color: var(--danger-color, #dc3545);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-danger:hover:not(:disabled) {
|
||||||
|
background-color: var(--danger-hover, #c82333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-success {
|
||||||
|
background-color: var(--success-color, #28a745);
|
||||||
|
color: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-success:hover:not(:disabled) {
|
||||||
|
background-color: var(--success-hover, #218838);
|
||||||
|
color: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-warning {
|
||||||
|
background-color: var(--warning-color, #ff9800);
|
||||||
|
color: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-warning:hover:not(:disabled) {
|
||||||
|
background-color: var(--warning-hover);
|
||||||
|
color: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-text {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-color, #007bff);
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-text:hover:not(:disabled) {
|
||||||
|
background-color: var(--text-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled state */
|
||||||
|
.app-button:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full width */
|
||||||
|
.app-button-full-width {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
.app-button-loading {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #fff;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-outline .app-button-spinner,
|
||||||
|
.app-button-text .app-button-spinner {
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-top-color: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon positioning */
|
||||||
|
.app-button-start-icon,
|
||||||
|
.app-button-end-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
91
src/components/common/Button/Button.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
import './Button.css';
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
/**
|
||||||
|
* Button visual variant
|
||||||
|
* @default 'primary'
|
||||||
|
*/
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'danger' | 'success' | 'text' | 'warning';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button size
|
||||||
|
* @default 'medium'
|
||||||
|
*/
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the button is in a loading state
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
isLoading?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon to display before the button text
|
||||||
|
*/
|
||||||
|
startIcon?: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon to display after the button text
|
||||||
|
*/
|
||||||
|
endIcon?: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full width button
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
fullWidth?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button content
|
||||||
|
*/
|
||||||
|
children: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional tooltip text displayed on hover
|
||||||
|
*/
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable button component with various styling options
|
||||||
|
*/
|
||||||
|
const ButtonComponent: React.FC<ButtonProps> = ({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'medium',
|
||||||
|
isLoading = false,
|
||||||
|
disabled = false,
|
||||||
|
startIcon,
|
||||||
|
endIcon,
|
||||||
|
fullWidth = false,
|
||||||
|
className = '',
|
||||||
|
tooltip,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}) => {
|
||||||
|
const buttonClasses = [
|
||||||
|
'app-button',
|
||||||
|
`app-button-${variant}`,
|
||||||
|
`app-button-${size}`,
|
||||||
|
isLoading && 'app-button-loading',
|
||||||
|
fullWidth && 'app-button-full-width',
|
||||||
|
className
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={buttonClasses}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
aria-busy={isLoading}
|
||||||
|
title={tooltip}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{isLoading && <span role="status" className="app-button-spinner"></span>}
|
||||||
|
{startIcon && !isLoading && <span className="app-button-start-icon">{startIcon}</span>}
|
||||||
|
<span className="app-button-text">{children}</span>
|
||||||
|
{endIcon && !isLoading && <span className="app-button-end-icon">{endIcon}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(ButtonComponent);
|
102
src/components/common/Modal/Modal.css
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
background-color: var(--surface-color, #292a2d);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: modal-fade-in 0.2s ease-out;
|
||||||
|
border: 1px solid var(--border-color, #3c4043);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #3c4043);
|
||||||
|
background-color: var(--surface-color, #292a2d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color, #e8eaed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary-color, #9aa0a6);
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-button:hover {
|
||||||
|
color: var(--text-color, #e8eaed);
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--surface-color, #292a2d);
|
||||||
|
color: var(--text-color, #e8eaed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-container {
|
||||||
|
width: 95%;
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
114
src/components/common/Modal/Modal.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import React, { ReactNode, useEffect, useRef } from 'react';
|
||||||
|
import './Modal.css';
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
/**
|
||||||
|
* Title displayed at the top of the modal
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content of the modal
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the modal is visible or not
|
||||||
|
*/
|
||||||
|
isOpen: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to call when the modal close button is clicked
|
||||||
|
*/
|
||||||
|
onClose: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional CSS class name to add to the modal for custom styling
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the close button in the header
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to close the modal when clicking outside of it
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
closeOnOutsideClick?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable modal component that can be used for various purposes
|
||||||
|
*/
|
||||||
|
const Modal: React.FC<ModalProps> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
className = '',
|
||||||
|
showCloseButton = true,
|
||||||
|
closeOnOutsideClick = true
|
||||||
|
}) => {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close modal when escape key is pressed
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscKey = (event: KeyboardEvent) => {
|
||||||
|
if (isOpen && event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscKey);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
// Handle outside click
|
||||||
|
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (closeOnOutsideClick && modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={handleBackdropClick}>
|
||||||
|
<div
|
||||||
|
className={`modal-container ${className}`}
|
||||||
|
ref={modalRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={title ? "modal-title" : undefined}
|
||||||
|
aria-describedby="modal-content"
|
||||||
|
>
|
||||||
|
{(title || showCloseButton) && (
|
||||||
|
<div className="modal-header">
|
||||||
|
{title && <h3 className="modal-title" id="modal-title">{title}</h3>}
|
||||||
|
{showCloseButton && (
|
||||||
|
<button
|
||||||
|
className="modal-close-button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="modal-content" id="modal-content">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
@ -0,0 +1,51 @@
|
|||||||
|
.notification-display {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 1500; /* Above most content, below critical modals? */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 250px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: inherit; /* Inherit color from parent */
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type-specific styles */
|
||||||
|
.notification-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: #202124; /* Dark text for contrast */
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
background-color: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: #202124; /* Dark text for contrast */
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useUIContext } from '../../../context/UIContext/useUIContext';
|
||||||
|
import './NotificationDisplay.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the current notification message from UIContext.
|
||||||
|
*/
|
||||||
|
export const NotificationDisplay: React.FC = () => {
|
||||||
|
const { warningMessage, clearWarningMessage } = useUIContext();
|
||||||
|
|
||||||
|
if (!warningMessage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text, type, id } = warningMessage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`notification-display notification-${type}`}>
|
||||||
|
<span className="notification-text">{text}</span>
|
||||||
|
{/* Add a close button only for persistent errors? */}
|
||||||
|
{type === 'error' && (
|
||||||
|
<button
|
||||||
|
className="notification-close-button"
|
||||||
|
onClick={clearWarningMessage}
|
||||||
|
aria-label="Close notification"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationDisplay;
|
69
src/components/common/ProgressBar/ProgressBar.css
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
.progress-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
background-color: var(--progress-bg, #f0f0f0);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
min-width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-percentage {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--progress-text, #fff);
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-values {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color variants */
|
||||||
|
.progress-bar-primary {
|
||||||
|
background-color: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-success {
|
||||||
|
background-color: var(--success-color, #28a745);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-warning {
|
||||||
|
background-color: var(--warning-color, #ffc107);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-error {
|
||||||
|
background-color: var(--error-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-info {
|
||||||
|
background-color: var(--info-color, #17a2b8);
|
||||||
|
}
|
114
src/components/common/ProgressBar/ProgressBar.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './ProgressBar.css';
|
||||||
|
|
||||||
|
export interface ProgressBarProps {
|
||||||
|
/**
|
||||||
|
* Current value of the progress (should be between min and max)
|
||||||
|
*/
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum value for the progress bar
|
||||||
|
* @default 100
|
||||||
|
*/
|
||||||
|
max?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum value for the progress bar
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
min?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label to display with the progress
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show percentage text inside the progress bar
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showPercentage?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional CSS class for custom styling
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress bar color
|
||||||
|
*/
|
||||||
|
color?: 'primary' | 'success' | 'warning' | 'error' | 'info';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to display the current value and max next to the bar
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
showValues?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format for displaying values (when showValues is true)
|
||||||
|
* @default "{value}/{max}"
|
||||||
|
*/
|
||||||
|
valueFormat?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable progress bar component that shows the progress of an operation
|
||||||
|
*/
|
||||||
|
const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
min = 0,
|
||||||
|
label,
|
||||||
|
showPercentage = true,
|
||||||
|
className = '',
|
||||||
|
color = 'primary',
|
||||||
|
showValues = false,
|
||||||
|
valueFormat = "{value}/{max}"
|
||||||
|
}) => {
|
||||||
|
// Calculate percentage
|
||||||
|
const range = max - min;
|
||||||
|
const valueInRange = Math.max(min, Math.min(max, value)) - min;
|
||||||
|
const percentage = range > 0 ? Math.round((valueInRange / range) * 100) : 0;
|
||||||
|
|
||||||
|
// Format value display
|
||||||
|
const formattedValue = valueFormat
|
||||||
|
.replace('{value}', value.toString())
|
||||||
|
.replace('{max}', max.toString())
|
||||||
|
.replace('{min}', min.toString())
|
||||||
|
.replace('{percentage}', `${percentage}%`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`progress-container ${className}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{label && <div className="progress-label" id={`progress-label-${label}`}>{label}</div>}
|
||||||
|
<div
|
||||||
|
className="progress-bar-wrapper"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={value}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-label={label ? undefined : 'Progress'}
|
||||||
|
aria-labelledby={label ? `progress-label-${label}` : undefined}
|
||||||
|
aria-valuetext={showValues ? formattedValue : `${percentage}%`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`progress-bar progress-bar-${color}`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
>
|
||||||
|
{showPercentage && (
|
||||||
|
<span className="progress-percentage">{percentage}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showValues && (
|
||||||
|
<div className="progress-values" aria-hidden="true">{formattedValue}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProgressBar;
|
62
src/components/layout/Footer/Footer.css
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background-color: var(--color-background-dark);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
height: 40px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left, .footer-center, .footer-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-center {
|
||||||
|
flex: 2;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-right {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-version {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-type {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-count {
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-link:hover {
|
||||||
|
color: var(--color-primary-light);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
45
src/components/layout/Footer/Footer.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
|
||||||
|
import { useServerContext } from '../../../context/ServerContext/useServerContext';
|
||||||
|
import './Footer.css';
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
appVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Footer: React.FC<FooterProps> = ({ appVersion = '1.0.0' }) => {
|
||||||
|
const { plugins } = usePluginContext();
|
||||||
|
const { serverInfo } = useServerContext();
|
||||||
|
|
||||||
|
const getServerTypeText = () => {
|
||||||
|
if (!serverInfo) return 'No server selected';
|
||||||
|
|
||||||
|
let text = `${serverInfo.server_type}`;
|
||||||
|
if (serverInfo.minecraft_version) {
|
||||||
|
text += ` (${serverInfo.minecraft_version})`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="footer">
|
||||||
|
<div className="footer-left">
|
||||||
|
<span className="footer-version">PlugSnatcher v{appVersion}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="footer-right">
|
||||||
|
<a
|
||||||
|
href="https://git.spacetrainclubhouse.com/Space-Train-Clubhouse/PlugSnatcher"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="github-link"
|
||||||
|
>
|
||||||
|
Gitea
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
112
src/components/layout/Header/Header.css
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: var(--color-background-dark);
|
||||||
|
color: var(--color-text-light);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left, .header-center, .header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
flex: 2;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-badge {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-path-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-label {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-path {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 300px;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-check-button {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-check-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-check-button:disabled {
|
||||||
|
background-color: var(--color-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
56
src/components/layout/Header/Header.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAppUpdates } from '../../../hooks/useAppUpdates';
|
||||||
|
import { useServerContext } from '../../../context/ServerContext/useServerContext';
|
||||||
|
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
|
||||||
|
import './Header.css';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
appVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({ appVersion = '1.0.0' }) => {
|
||||||
|
const { isCheckingAppUpdate, appUpdateAvailable, checkForAppUpdate } = useAppUpdates();
|
||||||
|
const { serverPath } = useServerContext();
|
||||||
|
const { isCheckingUpdates } = usePluginContext();
|
||||||
|
|
||||||
|
const handleCheckForUpdates = async () => {
|
||||||
|
if (!isCheckingAppUpdate) {
|
||||||
|
await checkForAppUpdate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<div className="header-left">
|
||||||
|
<h1 className="app-title">PlugSnatcher</h1>
|
||||||
|
<span className="app-version">v{appVersion}</span>
|
||||||
|
{appUpdateAvailable && (
|
||||||
|
<span className="update-badge">Update Available</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header-center">
|
||||||
|
{serverPath && (
|
||||||
|
<div className="server-path-display">
|
||||||
|
<span className="server-label">Selected Server:</span>
|
||||||
|
<span className="server-path" title={serverPath}>
|
||||||
|
{serverPath.length > 40 ? `...${serverPath.slice(-40)}` : serverPath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header-right">
|
||||||
|
<button
|
||||||
|
className="update-check-button"
|
||||||
|
onClick={handleCheckForUpdates}
|
||||||
|
disabled={isCheckingAppUpdate || isCheckingUpdates}
|
||||||
|
>
|
||||||
|
{isCheckingAppUpdate ? 'Checking...' : 'Check for Updates'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
78
src/components/layout/MainContent/MainContent.css
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
animation: slide-down 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-info {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-success {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-warning {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-error {
|
||||||
|
background-color: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-warning {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-warning:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down {
|
||||||
|
from {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
34
src/components/layout/MainContent/MainContent.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { useUIContext } from '../../../context/UIContext/useUIContext';
|
||||||
|
import './MainContent.css';
|
||||||
|
|
||||||
|
interface MainContentProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainContent: React.FC<MainContentProps> = ({ children }) => {
|
||||||
|
const { warningMessage, clearWarningMessage } = useUIContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="main-content">
|
||||||
|
{warningMessage && (
|
||||||
|
<div className={`warning-message warning-${warningMessage.type}`}>
|
||||||
|
<span className="warning-text">{warningMessage.text}</span>
|
||||||
|
<button
|
||||||
|
className="close-warning"
|
||||||
|
onClick={clearWarningMessage}
|
||||||
|
aria-label="Close message"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="content-container">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainContent;
|
74
src/components/plugins/NoPluginsMessage/NoPluginsMessage.css
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
.no-plugins-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
margin: 2rem auto;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-plugins-message h3 {
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-plugins-message p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-plugins-message .suggestion {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: rgba(var(--color-info-rgb), 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-plugins-message .icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-plugins-message.scanning {
|
||||||
|
background-color: rgba(var(--color-primary-rgb), 0.05);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-plugins-message.no-server {
|
||||||
|
background-color: rgba(var(--color-info-rgb), 0.05);
|
||||||
|
border-color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-plugins-message.empty {
|
||||||
|
background-color: rgba(var(--color-warning-rgb), 0.05);
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid rgba(var(--color-primary-rgb), 0.2);
|
||||||
|
border-left: 4px solid var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
49
src/components/plugins/NoPluginsMessage/NoPluginsMessage.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useServerActions } from '../../../hooks/useServerActions';
|
||||||
|
import './NoPluginsMessage.css';
|
||||||
|
|
||||||
|
export const NoPluginsMessage: React.FC = () => {
|
||||||
|
const { serverPath, scanComplete, isScanning } = useServerActions();
|
||||||
|
|
||||||
|
if (isScanning) {
|
||||||
|
return (
|
||||||
|
<div className="no-plugins-message scanning">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<h3>Scanning for plugins...</h3>
|
||||||
|
<p>This might take a moment, please wait.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverPath) {
|
||||||
|
return (
|
||||||
|
<div className="no-plugins-message no-server">
|
||||||
|
<div className="icon">📁</div>
|
||||||
|
<h3>No Server Selected</h3>
|
||||||
|
<p>Please select a Minecraft server directory to get started.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanComplete && !isScanning) {
|
||||||
|
return (
|
||||||
|
<div className="no-plugins-message empty">
|
||||||
|
<div className="icon">🔍</div>
|
||||||
|
<h3>No Plugins Found</h3>
|
||||||
|
<p>We couldn't find any plugins in the selected server directory.</p>
|
||||||
|
<p className="suggestion">Make sure you've selected a valid Minecraft server with plugins installed.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default message if we're in an intermediate state
|
||||||
|
return (
|
||||||
|
<div className="no-plugins-message">
|
||||||
|
<div className="icon">🧩</div>
|
||||||
|
<h3>Ready to Scan</h3>
|
||||||
|
<p>Click "Scan for Plugins" to discover plugins in your server.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NoPluginsMessage;
|
228
src/components/plugins/PluginDetails/PluginDetails.css
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
.plugin-details-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;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details-container {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
z-index: 1;
|
||||||
|
width: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details-content {
|
||||||
|
padding: 20px;
|
||||||
|
align-self: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background-color: var(--hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details-column {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.plugin-details-columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details-section h3 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--heading-color);
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: var(--heading-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-version-info {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-link {
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-description {
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-authors {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-website {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--link-color);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-website:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dependency-list li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-path {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info-value {
|
||||||
|
word-break: break-all;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background-color: var(--badge-background);
|
||||||
|
color: var(--badge-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-actions-section {
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
width: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
291
src/components/plugins/PluginDetails/PluginDetails.tsx
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
|
import { Plugin } from '../../../types/plugin.types';
|
||||||
|
import { usePluginActions } from '../../../hooks/usePluginActions';
|
||||||
|
import Badge from '../../common/Badge/Badge';
|
||||||
|
import Button from '../../common/Button/Button';
|
||||||
|
import './PluginDetails.css';
|
||||||
|
|
||||||
|
interface PluginDetailsProps {
|
||||||
|
plugin: Plugin;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a function to create repository URL
|
||||||
|
const getRepositoryUrl = (plugin: Plugin): string | null => {
|
||||||
|
// Return existing repository URL if available
|
||||||
|
if (plugin.repository_url) {
|
||||||
|
return plugin.repository_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to construct URL based on repository source and ID
|
||||||
|
if (plugin.repository_source && plugin.repository_id) {
|
||||||
|
switch (plugin.repository_source) {
|
||||||
|
case 'SpigotMC':
|
||||||
|
return `https://www.spigotmc.org/resources/${plugin.repository_id}`;
|
||||||
|
case 'Modrinth':
|
||||||
|
return `https://modrinth.com/plugin/${plugin.repository_id}`;
|
||||||
|
case 'GitHub':
|
||||||
|
return `https://github.com/${plugin.repository_id}/releases`;
|
||||||
|
case 'HangarMC':
|
||||||
|
return `https://hangar.papermc.io/plugins/${plugin.repository_id}`;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PluginDetails: React.FC<PluginDetailsProps> = ({ plugin, onClose }) => {
|
||||||
|
const { updatePlugin, checkSinglePlugin, pluginLoadingStates } = usePluginActions();
|
||||||
|
|
||||||
|
// Check if this specific plugin is being checked or updated
|
||||||
|
const isLoading = !!pluginLoadingStates[plugin.file_path];
|
||||||
|
|
||||||
|
// Handle escape key to close modal
|
||||||
|
const handleEscapeKey = useCallback((event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Handle clicking outside the modal to close
|
||||||
|
const handleOverlayClick = useCallback((event: React.MouseEvent) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Add event listener for escape key
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleEscapeKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscapeKey);
|
||||||
|
};
|
||||||
|
}, [handleEscapeKey]);
|
||||||
|
|
||||||
|
const handleCheckUpdate = async () => {
|
||||||
|
if (isLoading) return; // Prevent multiple clicks
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Checking for updates for plugin: ${plugin.name}`);
|
||||||
|
await checkSinglePlugin(plugin);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking updates for plugin ${plugin.name}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (isLoading) return; // Prevent multiple clicks
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Updating plugin: ${plugin.name}`);
|
||||||
|
await updatePlugin(plugin);
|
||||||
|
// Note: Any success/error notifications will be handled by the usePluginActions hook
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating plugin ${plugin.name}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number | null): string => {
|
||||||
|
if (!timestamp) return 'Unknown';
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDependencies = () => {
|
||||||
|
const dependencies = [
|
||||||
|
...(plugin.depend || []),
|
||||||
|
...(plugin.soft_depend || [])
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (dependencies.length === 0) {
|
||||||
|
return <p className="no-dependencies">No dependencies</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="dependency-list">
|
||||||
|
{dependencies.map((dep, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<span className="dependency-name">{dep}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSizeInMB = (bytes: number | undefined): string => {
|
||||||
|
if (!bytes) return 'Unknown';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="plugin-details-overlay" onClick={handleOverlayClick}>
|
||||||
|
<div className="plugin-details-container" role="dialog" aria-labelledby="plugin-details-title">
|
||||||
|
<div className="plugin-details-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="plugin-details-title" className="plugin-name">{plugin.name}</h2>
|
||||||
|
<div className="plugin-version-info">
|
||||||
|
<span className="version">
|
||||||
|
Version: {
|
||||||
|
plugin.repository_url || (plugin.repository_source && plugin.repository_id) ? (
|
||||||
|
<a
|
||||||
|
href={getRepositoryUrl(plugin) || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="version-link"
|
||||||
|
>
|
||||||
|
{plugin.version}
|
||||||
|
</a>
|
||||||
|
) : plugin.version
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{plugin.has_update && plugin.latest_version && (
|
||||||
|
<div className="update-info">
|
||||||
|
<Badge
|
||||||
|
label="Update Available"
|
||||||
|
variant="warning"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<span className="latest-version">
|
||||||
|
Latest: {
|
||||||
|
plugin.repository_url || (plugin.repository_source && plugin.repository_id) ? (
|
||||||
|
<a
|
||||||
|
href={getRepositoryUrl(plugin) || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="version-link"
|
||||||
|
>
|
||||||
|
{plugin.latest_version}
|
||||||
|
</a>
|
||||||
|
) : plugin.latest_version
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="close-button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close details"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-details-content">
|
||||||
|
<div className="plugin-details-section">
|
||||||
|
<h3>Description</h3>
|
||||||
|
<p className="plugin-description">
|
||||||
|
{plugin.description || 'No description available.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-details-columns">
|
||||||
|
<div className="plugin-details-column">
|
||||||
|
<div className="plugin-details-section">
|
||||||
|
<h3>Author Information</h3>
|
||||||
|
<p className="plugin-authors">
|
||||||
|
{plugin.authors && plugin.authors.length > 0
|
||||||
|
? plugin.authors.join(', ')
|
||||||
|
: 'Unknown author'}
|
||||||
|
</p>
|
||||||
|
{plugin.website && (
|
||||||
|
<a
|
||||||
|
href={plugin.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="plugin-website"
|
||||||
|
>
|
||||||
|
Visit Website
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-details-section">
|
||||||
|
<h3>Dependencies</h3>
|
||||||
|
{renderDependencies()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-details-column">
|
||||||
|
<div className="plugin-details-section">
|
||||||
|
<h3>File Information</h3>
|
||||||
|
<div className="file-info-item">
|
||||||
|
<span className="file-info-label">Path:</span>
|
||||||
|
<span className="file-info-value file-path">{plugin.file_path}</span>
|
||||||
|
</div>
|
||||||
|
<div className="file-info-item">
|
||||||
|
<span className="file-info-label">Hash:</span>
|
||||||
|
<span className="file-info-value">{plugin.file_hash.substring(0, 10)}...</span>
|
||||||
|
</div>
|
||||||
|
{plugin.repository_source && (
|
||||||
|
<div className="file-info-item">
|
||||||
|
<span className="file-info-label">Source:</span>
|
||||||
|
<span className="file-info-value">
|
||||||
|
{plugin.repository_url || getRepositoryUrl(plugin) ? (
|
||||||
|
<a
|
||||||
|
href={getRepositoryUrl(plugin) || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{plugin.repository_source}
|
||||||
|
</a>
|
||||||
|
) : plugin.repository_source}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{plugin.platform_compatibility && Object.keys(plugin.platform_compatibility).length > 0 && (
|
||||||
|
<div className="file-info-item">
|
||||||
|
<span className="file-info-label">Compatible with:</span>
|
||||||
|
<div className="platform-badges">
|
||||||
|
{Object.entries(plugin.platform_compatibility).map(([platform, isCompatible]) => (
|
||||||
|
<span
|
||||||
|
key={platform}
|
||||||
|
className="platform-badge"
|
||||||
|
data-server-type={platform}
|
||||||
|
title={`${isCompatible ? 'Compatible' : 'May not be compatible'} with ${platform}`}
|
||||||
|
>
|
||||||
|
{platform}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-actions-section">
|
||||||
|
{plugin.has_update ? (
|
||||||
|
<Button
|
||||||
|
variant="warning"
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={isLoading}
|
||||||
|
isLoading={isLoading}
|
||||||
|
tooltip={`Update ${plugin.name} to version ${plugin.latest_version}`}
|
||||||
|
aria-label={`Update ${plugin.name} to version ${plugin.latest_version}`}
|
||||||
|
>
|
||||||
|
Update to {plugin.latest_version}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCheckUpdate}
|
||||||
|
disabled={isLoading}
|
||||||
|
isLoading={isLoading}
|
||||||
|
tooltip={`Check for updates for ${plugin.name}`}
|
||||||
|
aria-label={`Check for updates for ${plugin.name}`}
|
||||||
|
>
|
||||||
|
Check for Updates
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginDetails;
|
172
src/components/plugins/PluginItem/PluginItem.css
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
.plugin-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3fr 1fr 1.5fr 1.5fr;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-item.has-update {
|
||||||
|
background-color: rgba(255, 152, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-item.has-update:hover {
|
||||||
|
background-color: rgba(255, 152, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-name-column {
|
||||||
|
padding-right: calc(var(--spacing-unit) * 2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-version-column {
|
||||||
|
padding: 0 calc(var(--spacing-unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-compatibility-column {
|
||||||
|
padding: 0 calc(var(--spacing-unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-actions-column {
|
||||||
|
justify-self: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-version {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-unit);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-version {
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-arrow {
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest-version {
|
||||||
|
color: var(--success-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-description {
|
||||||
|
margin: calc(var(--spacing-unit) * 0.75) 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-authors {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
margin-top: calc(var(--spacing-unit) * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: calc(var(--spacing-unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-unknown {
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: calc(var(--spacing-unit) * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
padding: calc(var(--spacing-unit) * 0.25) calc(var(--spacing-unit) * 0.75);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="Paper"] {
|
||||||
|
background-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="Spigot"] {
|
||||||
|
background-color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge[data-server-type="Bukkit"] {
|
||||||
|
background-color: #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom styles for plugin action buttons */
|
||||||
|
.info-button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-button {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive styles */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.plugin-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: calc(var(--spacing-unit) * 1.5);
|
||||||
|
padding: calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-name-column,
|
||||||
|
.plugin-version-column,
|
||||||
|
.plugin-compatibility-column,
|
||||||
|
.plugin-actions-column {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-actions-column {
|
||||||
|
justify-self: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-actions {
|
||||||
|
padding-top: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
}
|
159
src/components/plugins/PluginItem/PluginItem.tsx
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import React, { useCallback, memo } from 'react';
|
||||||
|
import { Plugin } from '../../../types/plugin.types';
|
||||||
|
import { usePluginActions } from '../../../hooks/usePluginActions';
|
||||||
|
import Badge from '../../common/Badge/Badge';
|
||||||
|
import Button from '../../common/Button/Button';
|
||||||
|
import './PluginItem.css';
|
||||||
|
|
||||||
|
interface PluginItemProps {
|
||||||
|
plugin: Plugin;
|
||||||
|
onSelect?: (plugin: Plugin) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginItemComponent: React.FC<PluginItemProps> = ({ plugin, onSelect }) => {
|
||||||
|
const { updatePlugin, checkSinglePlugin, pluginLoadingStates } = usePluginActions();
|
||||||
|
|
||||||
|
// Check if this specific plugin is being checked or updated
|
||||||
|
const isLoading = !!pluginLoadingStates[plugin.file_path];
|
||||||
|
|
||||||
|
const handlePluginClick = useCallback(() => {
|
||||||
|
if (onSelect) {
|
||||||
|
onSelect(plugin);
|
||||||
|
}
|
||||||
|
}, [onSelect, plugin]);
|
||||||
|
|
||||||
|
const handleUpdateClick = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation(); // Prevent triggering the parent click
|
||||||
|
if (isLoading) return; // Prevent multiple clicks while loading
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Updating plugin: ${plugin.name}`);
|
||||||
|
await updatePlugin(plugin);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating plugin ${plugin.name}:`, error);
|
||||||
|
}
|
||||||
|
}, [updatePlugin, plugin, isLoading]);
|
||||||
|
|
||||||
|
const handleCheckUpdateClick = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation(); // Prevent triggering the parent click
|
||||||
|
if (isLoading) return; // Prevent multiple clicks while loading
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Checking for updates for plugin: ${plugin.name}`);
|
||||||
|
await checkSinglePlugin(plugin);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking updates for plugin ${plugin.name}:`, error);
|
||||||
|
}
|
||||||
|
}, [checkSinglePlugin, plugin, isLoading]);
|
||||||
|
|
||||||
|
const handleInfoClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation(); // Prevent triggering the parent click
|
||||||
|
if (onSelect) {
|
||||||
|
console.log(`Viewing details for plugin: ${plugin.name}`);
|
||||||
|
onSelect(plugin);
|
||||||
|
}
|
||||||
|
}, [onSelect, plugin]);
|
||||||
|
|
||||||
|
// Get the plugin compatibility information for display, if available
|
||||||
|
const compatibility = plugin.platform_compatibility ? Object.keys(plugin.platform_compatibility).length > 0 : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`plugin-item ${plugin.has_update ? 'has-update' : ''}`}>
|
||||||
|
{/* Plugin Name Column */}
|
||||||
|
<div className="plugin-name-column">
|
||||||
|
<h3 className="plugin-name" title={plugin.name}>{plugin.name}</h3>
|
||||||
|
{plugin.authors && plugin.authors.length > 0 && (
|
||||||
|
<div className="plugin-authors">
|
||||||
|
By {plugin.authors.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="plugin-description" title={plugin.description}>
|
||||||
|
{plugin.description.length > 100
|
||||||
|
? `${plugin.description.substring(0, 100)}...`
|
||||||
|
: plugin.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version Column */}
|
||||||
|
<div className="plugin-version-column">
|
||||||
|
<div className="plugin-version">
|
||||||
|
<span className="current-version" title={`Current version: ${plugin.version}`}>v{plugin.version}</span>
|
||||||
|
{plugin.has_update && plugin.latest_version && (
|
||||||
|
<>
|
||||||
|
<span className="version-arrow">→</span>
|
||||||
|
<span className="latest-version" title={`Latest version: ${plugin.latest_version}`}>v{plugin.latest_version}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compatibility Column */}
|
||||||
|
<div className="plugin-compatibility-column">
|
||||||
|
{compatibility ? (
|
||||||
|
<div className="platform-badges">
|
||||||
|
{Object.entries(plugin.platform_compatibility || {}).map(([platform, isCompatible]) => (
|
||||||
|
<span
|
||||||
|
key={platform}
|
||||||
|
className="platform-badge"
|
||||||
|
data-server-type={platform}
|
||||||
|
title={`${isCompatible ? 'Compatible' : 'May not be compatible'} with ${platform}`}
|
||||||
|
>
|
||||||
|
{platform}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="compatibility-unknown">Unknown</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions Column */}
|
||||||
|
<div className="plugin-actions-column">
|
||||||
|
<div className="plugin-actions">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
onClick={handleInfoClick}
|
||||||
|
className="info-button"
|
||||||
|
tooltip={`View details for ${plugin.name}`}
|
||||||
|
aria-label={`View details for ${plugin.name}`}
|
||||||
|
>
|
||||||
|
Info
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{plugin.has_update ? (
|
||||||
|
<Button
|
||||||
|
variant="warning"
|
||||||
|
size="small"
|
||||||
|
onClick={handleUpdateClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="update-button"
|
||||||
|
tooltip={`Update ${plugin.name} to version ${plugin.latest_version}`}
|
||||||
|
aria-label={`Update ${plugin.name} to version ${plugin.latest_version}`}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="small"
|
||||||
|
onClick={handleCheckUpdateClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="check-button"
|
||||||
|
tooltip={`Check for updates for ${plugin.name}`}
|
||||||
|
aria-label={`Check for updates for ${plugin.name}`}
|
||||||
|
>
|
||||||
|
Check
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PluginItem = memo(PluginItemComponent);
|
164
src/components/plugins/PluginList/PluginList.css
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
.plugin-list-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-list-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-count {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outdated-badge {
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-list-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 2rem 0.6rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search {
|
||||||
|
position: absolute;
|
||||||
|
right: 2rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search:hover {
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-list-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-button.active {
|
||||||
|
background-color: rgba(26, 115, 232, 0.2);
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-info {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results button {
|
||||||
|
/* Button component will style this */
|
||||||
|
}
|
134
src/components/plugins/PluginList/PluginList.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { usePluginActions } from '../../../hooks/usePluginActions';
|
||||||
|
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
|
||||||
|
import { Plugin } from '../../../types/plugin.types';
|
||||||
|
import { PluginItem } from '../PluginItem/PluginItem';
|
||||||
|
import Button from '../../common/Button/Button';
|
||||||
|
import './PluginList.css';
|
||||||
|
|
||||||
|
export const PluginList: React.FC = () => {
|
||||||
|
const { plugins, isCheckingUpdates } = usePluginActions();
|
||||||
|
const { showPluginDetails } = usePluginContext();
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
|
const [sortBy, setSortBy] = useState<'name' | 'version' | 'update'>('name');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
|
// Filter plugins based on search term
|
||||||
|
const filteredPlugins = plugins.filter(plugin =>
|
||||||
|
plugin.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
plugin.version.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(plugin.description && plugin.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort plugins based on sort criteria
|
||||||
|
const sortedPlugins = [...filteredPlugins].sort((a, b) => {
|
||||||
|
let comparison = 0;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
comparison = a.name.localeCompare(b.name);
|
||||||
|
break;
|
||||||
|
case 'version':
|
||||||
|
comparison = a.version.localeCompare(b.version);
|
||||||
|
break;
|
||||||
|
case 'update':
|
||||||
|
// Sort by update status (plugins with updates first)
|
||||||
|
comparison = (b.has_update ? 1 : 0) - (a.has_update ? 1 : 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortOrder === 'asc' ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (criteria: 'name' | 'version' | 'update') => {
|
||||||
|
if (criteria === sortBy) {
|
||||||
|
// Toggle sort order if clicking the same criteria
|
||||||
|
setSortOrder(prevOrder => prevOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
// Set new criteria and default to ascending
|
||||||
|
setSortBy(criteria);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIndicator = (criteria: 'name' | 'version' | 'update'): string => {
|
||||||
|
if (sortBy !== criteria) return '';
|
||||||
|
return sortOrder === 'asc' ? ' ↑' : ' ↓';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="plugin-list-container">
|
||||||
|
<div className="search-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search plugins..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="search-input"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
className="clear-search"
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-list-controls">
|
||||||
|
<div className="sort-controls">
|
||||||
|
<span className="sort-label">Sort by:</span>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className={`sort-button ${sortBy === 'name' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
>
|
||||||
|
Name{getSortIndicator('name')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className={`sort-button ${sortBy === 'version' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSort('version')}
|
||||||
|
>
|
||||||
|
Version{getSortIndicator('version')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className={`sort-button ${sortBy === 'update' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSort('update')}
|
||||||
|
>
|
||||||
|
Updates{getSortIndicator('update')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-info">
|
||||||
|
Showing {filteredPlugins.length} of {plugins.length} plugins
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-list">
|
||||||
|
{sortedPlugins.length > 0 ? (
|
||||||
|
sortedPlugins.map(plugin => (
|
||||||
|
<PluginItem
|
||||||
|
key={plugin.file_path}
|
||||||
|
plugin={plugin}
|
||||||
|
onSelect={showPluginDetails}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="no-results">
|
||||||
|
<p>No plugins match your search.</p>
|
||||||
|
<Button variant="secondary" onClick={() => setSearchTerm('')}>Clear search</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginList;
|
78
src/components/server/ScanProgress/ScanProgress.css
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
.scan-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
animation: fade-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-progress-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-progress-title::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-progress-percentage {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-progress-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-progress-count {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
50
src/components/server/ScanProgress/ScanProgress.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ProgressBar from '../../common/ProgressBar/ProgressBar';
|
||||||
|
import './ScanProgress.css';
|
||||||
|
|
||||||
|
interface ScanProgressProps {
|
||||||
|
progress: { current: number; total: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScanProgress: React.FC<ScanProgressProps> = ({ progress }) => {
|
||||||
|
// Add console logging for debugging
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log("ScanProgress component received progress:", progress);
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
const calculatePercentage = (): number => {
|
||||||
|
if (!progress) return 0;
|
||||||
|
if (progress.total <= 0) return 0; // Handle case where total is 0 or negative
|
||||||
|
|
||||||
|
// Make sure current is at least 0 to prevent NaN
|
||||||
|
const current = Math.max(0, progress.current);
|
||||||
|
return Math.min(Math.round((current / progress.total) * 100), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentage = calculatePercentage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scan-progress">
|
||||||
|
<div className="scan-progress-header">
|
||||||
|
<span className="scan-progress-title">Scanning for plugins...</span>
|
||||||
|
<span className="scan-progress-percentage">{percentage}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
value={percentage}
|
||||||
|
color="primary"
|
||||||
|
showPercentage={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{progress && (
|
||||||
|
<div className="scan-progress-details">
|
||||||
|
<span className="scan-progress-count">
|
||||||
|
{progress.current} of {progress.total} files processed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScanProgress;
|
109
src/components/server/ServerInfo/ServerInfo.css
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
.server-info-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-type-name {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-path {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-path.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: color 0.2s;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-path.clickable:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-warning {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: rgba(255, 152, 0, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 4px solid var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-warning p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
109
src/components/server/ServerInfo/ServerInfo.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ServerInfo as ServerInfoType } from '../../../types/server.types';
|
||||||
|
import Badge from '../../common/Badge/Badge';
|
||||||
|
import './ServerInfo.css';
|
||||||
|
// Import only shell open as it's the primary method
|
||||||
|
import { open } from '@tauri-apps/plugin-shell';
|
||||||
|
import { useServerContext } from '../../../context/ServerContext/useServerContext';
|
||||||
|
|
||||||
|
interface ServerInfoProps {
|
||||||
|
serverInfo: ServerInfoType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerInfo: React.FC<ServerInfoProps> = ({ serverInfo }) => {
|
||||||
|
const { serverPath } = useServerContext();
|
||||||
|
|
||||||
|
const getServerTypeIcon = () => {
|
||||||
|
switch (serverInfo.server_type.toLowerCase()) {
|
||||||
|
case 'paper':
|
||||||
|
return '📄';
|
||||||
|
case 'spigot':
|
||||||
|
return '🔌';
|
||||||
|
case 'bukkit':
|
||||||
|
return '🪣';
|
||||||
|
case 'purpur':
|
||||||
|
return '🟣';
|
||||||
|
case 'fabric':
|
||||||
|
return '🧵';
|
||||||
|
case 'forge':
|
||||||
|
return '⚒️';
|
||||||
|
default:
|
||||||
|
return '🖥️';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatServerType = (type: string): string => {
|
||||||
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (): 'success' | 'warning' | 'error' => {
|
||||||
|
const version = serverInfo.minecraft_version;
|
||||||
|
if (!version) return 'warning';
|
||||||
|
|
||||||
|
// Parse version numbers like 1.19.2
|
||||||
|
const parts = version.split('.');
|
||||||
|
const major = parseInt(parts[0] || '0');
|
||||||
|
const minor = parseInt(parts[1] || '0');
|
||||||
|
|
||||||
|
// Check if using recent version (arbitrary definition)
|
||||||
|
if (major >= 1 && minor >= 18) {
|
||||||
|
return 'success';
|
||||||
|
} else if (major >= 1 && minor >= 16) {
|
||||||
|
return 'warning';
|
||||||
|
} else {
|
||||||
|
return 'error';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to open the server directory in file explorer
|
||||||
|
const handleOpenDirectory = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Attempting to open directory using shell.open:', serverPath);
|
||||||
|
// Use shell.open directly - it's generally more robust, especially for UNC
|
||||||
|
await open(serverPath);
|
||||||
|
console.log('Successfully opened directory via shell.open');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open directory:', error);
|
||||||
|
// Optionally add user feedback here (e.g., toast notification)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="server-info-container">
|
||||||
|
<div className="server-info-header">
|
||||||
|
<div className="server-icon">{getServerTypeIcon()}</div>
|
||||||
|
<div className="server-details">
|
||||||
|
<h3 className="server-type-name">
|
||||||
|
{formatServerType(serverInfo.server_type)}
|
||||||
|
<Badge
|
||||||
|
label={serverInfo.minecraft_version || 'Unknown Version'}
|
||||||
|
variant={getStatusColor()}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="server-path clickable"
|
||||||
|
onClick={handleOpenDirectory}
|
||||||
|
title="Click to open folder"
|
||||||
|
>
|
||||||
|
Server directory <span className="folder-icon">📂</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="server-stats">
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Plugins Directory</span>
|
||||||
|
<span className="stat-value">{serverInfo.plugins_directory}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-label">Plugins Found</span>
|
||||||
|
<span className="stat-value">{serverInfo.plugins_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerInfo;
|
32
src/components/server/ServerSelector/ServerSelector.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.server-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-selector-error {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: rgba(244, 67, 54, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 4px solid var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--error-color);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
98
src/components/server/ServerSelector/ServerSelector.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useServerActions } from '../../../hooks/useServerActions';
|
||||||
|
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
|
||||||
|
import Button from '../../common/Button/Button';
|
||||||
|
import ScanProgress from '../ScanProgress/ScanProgress';
|
||||||
|
import './ServerSelector.css';
|
||||||
|
|
||||||
|
export const ServerSelector: React.FC = () => {
|
||||||
|
const {
|
||||||
|
serverPath,
|
||||||
|
isScanning,
|
||||||
|
scanComplete,
|
||||||
|
scanProgress,
|
||||||
|
error,
|
||||||
|
selectDirectory,
|
||||||
|
scanForPlugins,
|
||||||
|
lastScanResult
|
||||||
|
} = useServerActions();
|
||||||
|
|
||||||
|
const { setPlugins } = usePluginContext();
|
||||||
|
|
||||||
|
// Effect to handle scan results
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastScanResult && lastScanResult.plugins) {
|
||||||
|
console.log("Setting plugins from lastScanResult in ServerSelector");
|
||||||
|
setPlugins(lastScanResult.plugins);
|
||||||
|
}
|
||||||
|
}, [lastScanResult, setPlugins]);
|
||||||
|
|
||||||
|
const handleSelectDirectory = async () => {
|
||||||
|
await selectDirectory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScanForPlugins = async () => {
|
||||||
|
await scanForPlugins();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetSelection = () => {
|
||||||
|
// Clear server path and plugins
|
||||||
|
setPlugins([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="server-selector">
|
||||||
|
<div className="server-actions">
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectDirectory}
|
||||||
|
disabled={isScanning}
|
||||||
|
variant="primary"
|
||||||
|
tooltip="Choose the root folder of your Minecraft server"
|
||||||
|
>
|
||||||
|
Select Server Directory
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{serverPath && !isScanning && (
|
||||||
|
<Button
|
||||||
|
onClick={handleScanForPlugins}
|
||||||
|
disabled={!serverPath || isScanning}
|
||||||
|
isLoading={isScanning}
|
||||||
|
variant="success"
|
||||||
|
tooltip="Scan the selected server directory for plugins"
|
||||||
|
>
|
||||||
|
Scan for Plugins
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{serverPath && scanComplete && (
|
||||||
|
<Button
|
||||||
|
onClick={handleResetSelection}
|
||||||
|
disabled={isScanning}
|
||||||
|
variant="secondary"
|
||||||
|
tooltip="Clear the current server selection and plugin list"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isScanning && (
|
||||||
|
<>
|
||||||
|
{/* Add debug info */}
|
||||||
|
<div style={{ display: 'none' }}>
|
||||||
|
<pre>Debug - scanProgress: {JSON.stringify(scanProgress, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
<ScanProgress progress={scanProgress} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="server-selector-error">
|
||||||
|
<p className="error-message" role="alert">Error: {error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerSelector;
|
@ -0,0 +1,87 @@
|
|||||||
|
.bulk-update-progress {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
animation: slide-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-update-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-update-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-update-title::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-update-count {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-update-details {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-plugin {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-plugin strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
|
||||||
|
import ProgressBar from '../../common/ProgressBar/ProgressBar';
|
||||||
|
import './BulkUpdateProgress.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays the progress of bulk plugin updates
|
||||||
|
*/
|
||||||
|
export const BulkUpdateProgress: React.FC = () => {
|
||||||
|
const { bulkUpdateProgress } = usePluginContext();
|
||||||
|
|
||||||
|
// If no bulk update is in progress, don't render anything
|
||||||
|
if (!bulkUpdateProgress) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercentage = bulkUpdateProgress.total > 0
|
||||||
|
? Math.round((bulkUpdateProgress.processed / bulkUpdateProgress.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bulk-update-progress">
|
||||||
|
<div className="bulk-update-header">
|
||||||
|
<h3 className="bulk-update-title">
|
||||||
|
Checking for Updates
|
||||||
|
</h3>
|
||||||
|
<span className="bulk-update-count">
|
||||||
|
{bulkUpdateProgress.processed} of {bulkUpdateProgress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
value={progressPercentage}
|
||||||
|
color={progressPercentage === 100 ? 'success' : 'primary'}
|
||||||
|
showPercentage={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bulk-update-details">
|
||||||
|
<span className="current-plugin">
|
||||||
|
Processing: <strong>{bulkUpdateProgress.current_plugin_name}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BulkUpdateProgress;
|
@ -0,0 +1,81 @@
|
|||||||
|
.compatibility-check-content {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-status {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-supported,
|
||||||
|
.compatibility-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-supported {
|
||||||
|
background-color: rgba(76, 175, 80, 0.1);
|
||||||
|
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-warning {
|
||||||
|
background-color: rgba(255, 152, 0, 0.1);
|
||||||
|
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compatibility-status p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-details li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.risk-acknowledgment {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input[type="checkbox"] {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
@ -0,0 +1,112 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Modal from '../../common/Modal/Modal';
|
||||||
|
import Button from '../../common/Button/Button';
|
||||||
|
import './CompatibilityCheckDialog.css';
|
||||||
|
import { useServerContext } from '../../../context/ServerContext/useServerContext';
|
||||||
|
import { Plugin } from '../../../types/plugin.types';
|
||||||
|
|
||||||
|
interface CompatibilityCheckDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
plugin: Plugin;
|
||||||
|
onConfirmUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that shows compatibility warnings before updating a plugin
|
||||||
|
*/
|
||||||
|
export const CompatibilityCheckDialog: React.FC<CompatibilityCheckDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
plugin,
|
||||||
|
onConfirmUpdate
|
||||||
|
}) => {
|
||||||
|
const { serverInfo } = useServerContext();
|
||||||
|
const [acknowledgedRisk, setAcknowledgedRisk] = useState(false);
|
||||||
|
|
||||||
|
// Determine if the plugin explicitly supports this server version
|
||||||
|
const serverVersion = serverInfo?.minecraft_version || '';
|
||||||
|
const supportsVersion = plugin.platform_compatibility?.includes(serverVersion) || false;
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onConfirmUpdate();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Compatibility Check"
|
||||||
|
>
|
||||||
|
<div className="compatibility-check-content">
|
||||||
|
<div className="compatibility-status">
|
||||||
|
{supportsVersion ? (
|
||||||
|
<div className="compatibility-supported">
|
||||||
|
<span className="status-icon">✓</span>
|
||||||
|
<p>
|
||||||
|
This plugin update is marked as compatible with your server version ({serverVersion}).
|
||||||
|
It should work correctly after updating.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="compatibility-warning">
|
||||||
|
<span className="status-icon">⚠️</span>
|
||||||
|
<p>
|
||||||
|
This plugin update is not explicitly marked as compatible with your server version ({serverVersion}).
|
||||||
|
Updating may cause compatibility issues or server errors.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-details">
|
||||||
|
<h4>Plugin Details</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Name:</strong> {plugin.name}</li>
|
||||||
|
<li><strong>Current Version:</strong> {plugin.version}</li>
|
||||||
|
<li><strong>Latest Version:</strong> {plugin.latest_version}</li>
|
||||||
|
{plugin.platform_compatibility && (
|
||||||
|
<li>
|
||||||
|
<strong>Supported Versions:</strong> {plugin.platform_compatibility.join(', ')}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!supportsVersion && (
|
||||||
|
<div className="risk-acknowledgment">
|
||||||
|
<label className="checkbox-container">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acknowledgedRisk}
|
||||||
|
onChange={() => setAcknowledgedRisk(!acknowledgedRisk)}
|
||||||
|
/>
|
||||||
|
<span className="checkbox-label">
|
||||||
|
I understand the risks and still want to update this plugin
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
variant="primary"
|
||||||
|
disabled={!supportsVersion && !acknowledgedRisk}
|
||||||
|
>
|
||||||
|
Update Anyway
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompatibilityCheckDialog;
|
@ -0,0 +1,106 @@
|
|||||||
|
.download-progress-indicator {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-progress-plugin-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-version {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-status {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-message {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status-specific styles */
|
||||||
|
.download-status-downloading .status-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-status-extracting .status-icon,
|
||||||
|
.download-status-installing .status-icon {
|
||||||
|
color: var(--color-primary);
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-status-completed {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-status-completed .status-icon {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-status-error {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-status-error .status-icon,
|
||||||
|
.download-status-error .download-message {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ProgressBar from '../../common/ProgressBar/ProgressBar';
|
||||||
|
import { DownloadProgress } from '../../../types/events.types';
|
||||||
|
import './DownloadProgressIndicator.css';
|
||||||
|
|
||||||
|
interface DownloadProgressIndicatorProps {
|
||||||
|
downloadProgress: DownloadProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays the progress of a plugin download
|
||||||
|
*/
|
||||||
|
export const DownloadProgressIndicator: React.FC<DownloadProgressIndicatorProps> = ({
|
||||||
|
downloadProgress
|
||||||
|
}) => {
|
||||||
|
const { pluginName, version, percentage, status, message } = downloadProgress;
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'downloading':
|
||||||
|
return 'Downloading';
|
||||||
|
case 'extracting':
|
||||||
|
return 'Extracting';
|
||||||
|
case 'installing':
|
||||||
|
return 'Installing';
|
||||||
|
case 'completed':
|
||||||
|
return 'Completed';
|
||||||
|
case 'error':
|
||||||
|
return 'Error';
|
||||||
|
default:
|
||||||
|
return 'Processing';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'downloading':
|
||||||
|
return '↓';
|
||||||
|
case 'extracting':
|
||||||
|
return '📦';
|
||||||
|
case 'installing':
|
||||||
|
return '⚙️';
|
||||||
|
case 'completed':
|
||||||
|
return '✓';
|
||||||
|
case 'error':
|
||||||
|
return '⚠️';
|
||||||
|
default:
|
||||||
|
return '•';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'success';
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'primary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`download-progress-indicator download-status-${status}`}>
|
||||||
|
<div className="download-progress-header">
|
||||||
|
<div className="download-progress-plugin-info">
|
||||||
|
<span className="status-icon">{getStatusIcon()}</span>
|
||||||
|
<span className="plugin-name">{pluginName}</span>
|
||||||
|
<span className="plugin-version">v{version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="download-status">
|
||||||
|
{getStatusText()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
value={percentage}
|
||||||
|
color={getStatusColor()}
|
||||||
|
showPercentage={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="download-message">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DownloadProgressIndicator;
|
@ -0,0 +1,132 @@
|
|||||||
|
.plugin-match-selector {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-header {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-header p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-matches-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-item {
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-item:hover {
|
||||||
|
background-color: var(--color-background-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-item.selected {
|
||||||
|
background-color: var(--color-background-active);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-info {
|
||||||
|
flex: 0 0 25%;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-name {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-version,
|
||||||
|
.plugin-match-source {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-description {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-mc-versions,
|
||||||
|
.plugin-match-downloads {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-selection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-radio {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-radio input {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.plugin-match-item {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-info {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding-right: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-match-selection {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,111 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Modal from '../../common/Modal/Modal';
|
||||||
|
import Button from '../../common/Button/Button';
|
||||||
|
import { PotentialPluginMatch } from '../../../types/plugin.types';
|
||||||
|
import './PluginMatchSelector.css';
|
||||||
|
|
||||||
|
interface PluginMatchSelectorProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
pluginName: string;
|
||||||
|
potentialMatches: PotentialPluginMatch[];
|
||||||
|
onSelectMatch: (match: PotentialPluginMatch) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that allows users to select from potential plugin matches when the exact match isn't found
|
||||||
|
*/
|
||||||
|
export const PluginMatchSelector: React.FC<PluginMatchSelectorProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
pluginName,
|
||||||
|
potentialMatches,
|
||||||
|
onSelectMatch
|
||||||
|
}) => {
|
||||||
|
const [selectedMatchIndex, setSelectedMatchIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleSelectMatch = () => {
|
||||||
|
if (selectedMatchIndex !== null) {
|
||||||
|
onSelectMatch(potentialMatches[selectedMatchIndex]);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Select Plugin Match"
|
||||||
|
>
|
||||||
|
<div className="plugin-match-selector">
|
||||||
|
<div className="plugin-match-header">
|
||||||
|
<p>
|
||||||
|
We found multiple potential matches for <strong>{pluginName}</strong>.
|
||||||
|
Please select the correct plugin from the list below:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-matches-list">
|
||||||
|
{potentialMatches.map((match, index) => (
|
||||||
|
<div
|
||||||
|
key={`${match.repository}-${match.name}-${match.version}`}
|
||||||
|
className={`plugin-match-item ${selectedMatchIndex === index ? 'selected' : ''}`}
|
||||||
|
onClick={() => setSelectedMatchIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="plugin-match-info">
|
||||||
|
<h4 className="plugin-match-name">{match.name}</h4>
|
||||||
|
<span className="plugin-match-version">v{match.version}</span>
|
||||||
|
<span className="plugin-match-source">{match.repository}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-match-details">
|
||||||
|
{match.description && (
|
||||||
|
<p className="plugin-match-description">{match.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="plugin-match-metadata">
|
||||||
|
<span className="plugin-match-mc-versions">
|
||||||
|
MC: {match.minecraft_versions.join(', ')}
|
||||||
|
</span>
|
||||||
|
{match.download_count !== undefined && (
|
||||||
|
<span className="plugin-match-downloads">
|
||||||
|
{new Intl.NumberFormat().format(match.download_count)} downloads
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-match-selection">
|
||||||
|
<div className="plugin-match-radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="plugin-match"
|
||||||
|
checked={selectedMatchIndex === index}
|
||||||
|
onChange={() => setSelectedMatchIndex(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-match-actions">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectMatch}
|
||||||
|
variant="primary"
|
||||||
|
disabled={selectedMatchIndex === null}
|
||||||
|
>
|
||||||
|
Select Plugin
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginMatchSelector;
|
@ -0,0 +1,53 @@
|
|||||||
|
.premium-plugin-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-plugin-icon {
|
||||||
|
color: #ffc107;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-plugin-message {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-plugin-message h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-plugin-message p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-plugin-message strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.premium-plugin-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from '../../common/Modal/Modal';
|
||||||
|
import Button from '../../common/Button/Button';
|
||||||
|
import './PremiumPluginModal.css';
|
||||||
|
import { PremiumPluginInfo } from '../../../types/events.types';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
interface PremiumPluginModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
pluginInfo: PremiumPluginInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal that displays information about a premium plugin that requires manual download
|
||||||
|
*/
|
||||||
|
export const PremiumPluginModal: React.FC<PremiumPluginModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
pluginInfo
|
||||||
|
}) => {
|
||||||
|
const handleOpenUrl = async () => {
|
||||||
|
try {
|
||||||
|
// Use Tauri invoke to call Rust command to open the URL
|
||||||
|
await invoke('plugin:shell|open', { url: pluginInfo.url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open URL:', error);
|
||||||
|
// Fallback to standard window.open if invoke fails
|
||||||
|
window.open(pluginInfo.url, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Premium Plugin"
|
||||||
|
>
|
||||||
|
<div className="premium-plugin-content">
|
||||||
|
<div className="premium-plugin-icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
|
||||||
|
<path d="M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="premium-plugin-message">
|
||||||
|
<h3>Premium Plugin Detected</h3>
|
||||||
|
<p>
|
||||||
|
<strong>{pluginInfo.name}</strong> (version {pluginInfo.version}) is a premium plugin
|
||||||
|
that requires manual download from the official source.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Click the button below to visit the plugin's website where you can purchase or
|
||||||
|
download the update if you already own it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="premium-plugin-actions">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleOpenUrl}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
Open Download Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PremiumPluginModal;
|
114
src/components/updates/UpdateControls/UpdateControls.css
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
.update-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checking-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner.small {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-available {
|
||||||
|
color: var(--warning-color);
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--warning-color);
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-updates {
|
||||||
|
color: var(--success-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-updates-checked {
|
||||||
|
color: var(--text-secondary-color);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-update-available {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background-color: rgba(76, 175, 80, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon, .update-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.update-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
105
src/components/updates/UpdateControls/UpdateControls.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useUpdateActions } from '../../../hooks/useUpdateActions';
|
||||||
|
import { usePluginActions } from '../../../hooks/usePluginActions';
|
||||||
|
import Button from '../../common/Button/Button';
|
||||||
|
import './UpdateControls.css';
|
||||||
|
|
||||||
|
export const UpdateControls: React.FC = () => {
|
||||||
|
const {
|
||||||
|
updateAllPlugins,
|
||||||
|
checkForAllUpdates,
|
||||||
|
bulkUpdateProgress
|
||||||
|
} = useUpdateActions();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isCheckingUpdates,
|
||||||
|
getOutdatedPluginsCount,
|
||||||
|
plugins
|
||||||
|
} = usePluginActions();
|
||||||
|
|
||||||
|
// Local loading state for the "Update All" button
|
||||||
|
const [isUpdatingAll, setIsUpdatingAll] = useState(false);
|
||||||
|
|
||||||
|
// Calculate how many plugins need updates
|
||||||
|
const outdatedCount = getOutdatedPluginsCount();
|
||||||
|
const hasUpdates = outdatedCount > 0;
|
||||||
|
|
||||||
|
// Track if updates have been checked (any plugin has has_update property set)
|
||||||
|
const updatesHaveBeenChecked = plugins.some(plugin => plugin.has_update !== undefined);
|
||||||
|
|
||||||
|
// Determine overall loading state
|
||||||
|
const isLoading = isCheckingUpdates || isUpdatingAll;
|
||||||
|
|
||||||
|
const handleCheckForUpdates = async () => {
|
||||||
|
if (isLoading) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Starting plugin update check");
|
||||||
|
await checkForAllUpdates();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking for updates:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateAll = async () => {
|
||||||
|
if (isLoading || !hasUpdates) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUpdatingAll(true);
|
||||||
|
await updateAllPlugins();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating all plugins:", error);
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="update-controls" role="region" aria-label="Plugin update controls">
|
||||||
|
<div className="update-status">
|
||||||
|
{isCheckingUpdates ? (
|
||||||
|
<div className="checking-status" aria-live="polite">
|
||||||
|
<div className="spinner small" aria-hidden="true"></div>
|
||||||
|
<span>Checking for plugin updates{bulkUpdateProgress ? ` (${bulkUpdateProgress.processed}/${bulkUpdateProgress.total})` : '...'}</span>
|
||||||
|
</div>
|
||||||
|
) : hasUpdates ? (
|
||||||
|
<div className="updates-available" aria-live="polite">
|
||||||
|
<span className="update-count">{outdatedCount}</span> plugin update{outdatedCount !== 1 ? 's' : ''} available
|
||||||
|
</div>
|
||||||
|
) : updatesHaveBeenChecked ? (
|
||||||
|
<div className="no-updates" aria-live="polite">All plugins are up to date</div>
|
||||||
|
) : (
|
||||||
|
<div className="no-updates-checked">Click "Check for Updates" to check plugin versions</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="update-actions">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleCheckForUpdates}
|
||||||
|
disabled={isLoading}
|
||||||
|
isLoading={isCheckingUpdates}
|
||||||
|
startIcon={!isCheckingUpdates ? <span className="check-icon" aria-hidden="true">↻</span> : undefined}
|
||||||
|
aria-label="Check for plugin updates"
|
||||||
|
>
|
||||||
|
Check for Updates
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasUpdates && (
|
||||||
|
<Button
|
||||||
|
variant="warning"
|
||||||
|
onClick={handleUpdateAll}
|
||||||
|
disabled={isLoading}
|
||||||
|
isLoading={isUpdatingAll}
|
||||||
|
startIcon={<span className="update-icon" aria-hidden="true">↑</span>}
|
||||||
|
aria-label={`Update all ${outdatedCount} plugins`}
|
||||||
|
>
|
||||||
|
Update All ({outdatedCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateControls;
|
62
src/components/updates/WarningModal/WarningModal.css
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
.warning-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal-icon {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal-warning .warning-modal-icon {
|
||||||
|
color: #ff9800; /* warning color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal-error .warning-modal-icon {
|
||||||
|
color: #f44336; /* error color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal-info .warning-modal-icon {
|
||||||
|
color: #2196f3; /* info color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal-content {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal-message {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 1rem;
|
||||||
|
white-space: pre-line; /* Respects line breaks in the message */
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add a subtle shake animation for error warnings */
|
||||||
|
.warning-modal-error {
|
||||||
|
animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
10%, 90% {
|
||||||
|
transform: translateX(-1px);
|
||||||
|
}
|
||||||
|
20%, 80% {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
30%, 50%, 70% {
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
40%, 60% {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
}
|
107
src/components/updates/WarningModal/WarningModal.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from '../../common/Modal/Modal';
|
||||||
|
import Button from '../../common/Button/Button';
|
||||||
|
import './WarningModal.css';
|
||||||
|
|
||||||
|
interface WarningModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
variant?: 'warning' | 'error' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modal component for displaying important warnings to users
|
||||||
|
*/
|
||||||
|
export const WarningModal: React.FC<WarningModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
variant = 'warning'
|
||||||
|
}) => {
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (onConfirm) {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Choose icon based on variant
|
||||||
|
const renderIcon = () => {
|
||||||
|
switch (variant) {
|
||||||
|
case 'error':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
|
||||||
|
<path d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'info':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
|
||||||
|
<path d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'warning':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
|
||||||
|
<path d="M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<div className={`warning-modal warning-modal-${variant}`}>
|
||||||
|
<div className="warning-modal-icon">
|
||||||
|
{renderIcon()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="warning-modal-content">
|
||||||
|
<p className="warning-modal-message">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="warning-modal-actions">
|
||||||
|
{onConfirm ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
variant={variant === 'error' ? 'danger' : 'primary'}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WarningModal;
|
504
src/context/PluginContext/PluginContext.tsx
Normal file
@ -0,0 +1,504 @@
|
|||||||
|
import React, { createContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { Plugin, PotentialPluginMatch } from '../../types/plugin.types';
|
||||||
|
import { BulkUpdateProgressPayload, SingleUpdateResultPayload } from '../../types/events.types';
|
||||||
|
import { ScanResult } from '../../types/server.types';
|
||||||
|
import { canUpdatePlugin } from '../../utils/validators';
|
||||||
|
import { createUpdateMessage } from '../../utils/formatters';
|
||||||
|
import { ServerType } from '../../types/server.types';
|
||||||
|
import { useServerContext } from '../ServerContext/useServerContext';
|
||||||
|
|
||||||
|
interface PluginContextProps {
|
||||||
|
/**
|
||||||
|
* List of plugins installed on the server
|
||||||
|
*/
|
||||||
|
plugins: Plugin[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently selected plugin (for details view)
|
||||||
|
*/
|
||||||
|
selectedPlugin: Plugin | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether plugin updates are being checked
|
||||||
|
*/
|
||||||
|
isCheckingUpdates: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message specific to plugin operations
|
||||||
|
*/
|
||||||
|
updateError: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading states for individual plugins (keyed by file_path)
|
||||||
|
*/
|
||||||
|
pluginLoadingStates: Record<string, boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress information for bulk update checks
|
||||||
|
*/
|
||||||
|
bulkUpdateProgress: BulkUpdateProgressPayload | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a single plugin update check is in progress
|
||||||
|
*/
|
||||||
|
isCheckingSinglePlugin: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to check for updates for all plugins
|
||||||
|
*/
|
||||||
|
checkForUpdates: (serverType?: ServerType) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to check for updates for a single plugin
|
||||||
|
*/
|
||||||
|
checkSinglePlugin: (plugin: Plugin) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to update a plugin to the latest version
|
||||||
|
*/
|
||||||
|
updatePlugin: (plugin: Plugin) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to select a plugin for viewing details
|
||||||
|
*/
|
||||||
|
showPluginDetails: (plugin: Plugin) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to close the plugin details view
|
||||||
|
*/
|
||||||
|
closePluginDetails: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to set the plugins array directly
|
||||||
|
*/
|
||||||
|
setPlugins: (plugins: Plugin[]) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to clear update errors
|
||||||
|
*/
|
||||||
|
clearUpdateError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the context with default values
|
||||||
|
export const PluginContext = createContext<PluginContextProps>({} as PluginContextProps);
|
||||||
|
|
||||||
|
interface PluginProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider component for managing plugin-related state
|
||||||
|
*/
|
||||||
|
export const PluginProvider: React.FC<PluginProviderProps> = ({
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
// Get server context directly
|
||||||
|
const { serverPath, serverInfo } = useServerContext();
|
||||||
|
const serverType = serverInfo?.server_type;
|
||||||
|
|
||||||
|
const [plugins, setPluginsState] = useState<Plugin[]>([]);
|
||||||
|
const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null);
|
||||||
|
const [isCheckingUpdates, setIsCheckingUpdates] = useState<boolean>(false);
|
||||||
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||||
|
const [pluginLoadingStates, setPluginLoadingStates] = useState<Record<string, boolean>>({});
|
||||||
|
const [bulkUpdateProgress, setBulkUpdateProgress] = useState<BulkUpdateProgressPayload | null>(null);
|
||||||
|
const [isCheckingSinglePlugin, setIsCheckingSinglePlugin] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisteners: (() => void)[] = [];
|
||||||
|
|
||||||
|
// Listen for scan-completed events to update plugins
|
||||||
|
listen('scan_completed', (event) => {
|
||||||
|
console.log("Received scan_completed event in PluginContext:", event.payload);
|
||||||
|
const result = event.payload as ScanResult;
|
||||||
|
|
||||||
|
// Update the plugins state with the scanned plugins
|
||||||
|
setPluginsState(result.plugins);
|
||||||
|
console.log(`Updated plugins state with ${result.plugins.length} plugins`);
|
||||||
|
}).then(unlisten => unlisteners.push(unlisten));
|
||||||
|
|
||||||
|
// Listen for update check progress
|
||||||
|
listen<BulkUpdateProgressPayload>("update_check_progress", (event) => {
|
||||||
|
console.log("Update check progress event received:", event.payload);
|
||||||
|
setBulkUpdateProgress(event.payload);
|
||||||
|
}).then(unlisten => unlisteners.push(unlisten));
|
||||||
|
|
||||||
|
// Listen for single update check completed
|
||||||
|
listen<SingleUpdateResultPayload>("single_update_check_completed", (eventData) => {
|
||||||
|
const { original_file_path, plugin: updatedPlugin, error } = eventData.payload;
|
||||||
|
console.log("Single update check completed event received:", original_file_path);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error checking plugin for updates:", error);
|
||||||
|
setUpdateError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedPlugin) {
|
||||||
|
console.log("Plugin update check result:", updatedPlugin);
|
||||||
|
|
||||||
|
// Update the plugin in the list
|
||||||
|
setPluginsState(currentPlugins => {
|
||||||
|
return currentPlugins.map(p => {
|
||||||
|
if (p.file_path === original_file_path) {
|
||||||
|
return updatedPlugin;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear loading state for this plugin - IMPORTANT: always clear the loading state
|
||||||
|
console.log(`Clearing loading state for plugin path: ${original_file_path}`);
|
||||||
|
setPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[original_file_path];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also notify the UI that the check is complete
|
||||||
|
const customEvent = new CustomEvent('single_plugin_check_completed', {
|
||||||
|
detail: {
|
||||||
|
plugin_path: original_file_path,
|
||||||
|
success: !error,
|
||||||
|
error: error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(customEvent);
|
||||||
|
}).then(unlisten => unlisteners.push(unlisten));
|
||||||
|
|
||||||
|
// Listen for update check complete
|
||||||
|
listen("update_check_complete", (event) => {
|
||||||
|
console.log("Update check complete event received:", event);
|
||||||
|
|
||||||
|
// Optionally handle any additional logic needed when bulk update is complete
|
||||||
|
}).then(unlisten => unlisteners.push(unlisten));
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
unlisteners.forEach(unlisten => unlisten());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set plugins directly
|
||||||
|
const setPlugins = useCallback((newPlugins: Plugin[]) => {
|
||||||
|
setPluginsState(newPlugins);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear update error
|
||||||
|
const clearUpdateError = useCallback(() => {
|
||||||
|
setUpdateError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Log when serverPath changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`PluginContext: serverPath changed to ${serverPath}`);
|
||||||
|
}, [serverPath]);
|
||||||
|
|
||||||
|
// Check for updates for all plugins
|
||||||
|
const checkForUpdates = useCallback(async (currentServerType?: ServerType) => {
|
||||||
|
const currentServerPath = serverPath;
|
||||||
|
console.log(`checkForUpdates called with serverPath: ${currentServerPath}`);
|
||||||
|
|
||||||
|
if (!plugins.length) {
|
||||||
|
console.error('No plugins to check for updates');
|
||||||
|
setUpdateError('No plugins to check for updates');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCheckingUpdates) {
|
||||||
|
console.warn('Update check already in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentServerPath) {
|
||||||
|
console.error('No server path available in PluginContext');
|
||||||
|
setUpdateError('No server path available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Starting update check with serverPath: ${currentServerPath}`);
|
||||||
|
console.log(`Total plugins to check: ${plugins.length}`);
|
||||||
|
console.log(`Server type: ${currentServerType || serverType || 'Unknown'}`);
|
||||||
|
|
||||||
|
setIsCheckingUpdates(true);
|
||||||
|
setUpdateError(null);
|
||||||
|
setBulkUpdateProgress(null);
|
||||||
|
console.log("Invoking bulk check_plugin_updates...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Include all repositories to check
|
||||||
|
const repositoriesToCheck = ['hangarmc', 'spigotmc', 'modrinth', 'github'];
|
||||||
|
|
||||||
|
// Prepare plugins data with correct structure
|
||||||
|
const pluginsToSend = plugins.map(p => ({
|
||||||
|
name: p.name,
|
||||||
|
version: p.version,
|
||||||
|
authors: p.authors || [],
|
||||||
|
file_path: p.file_path,
|
||||||
|
file_hash: p.file_hash,
|
||||||
|
website: p.website,
|
||||||
|
description: p.description,
|
||||||
|
api_version: p.api_version,
|
||||||
|
main_class: p.main_class,
|
||||||
|
depend: p.depend,
|
||||||
|
soft_depend: p.soft_depend,
|
||||||
|
load_before: p.load_before,
|
||||||
|
commands: p.commands,
|
||||||
|
permissions: p.permissions,
|
||||||
|
has_update: p.has_update || false,
|
||||||
|
repository_source: p.repository_source,
|
||||||
|
repository_id: p.repository_id,
|
||||||
|
repository_url: p.repository_url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("Sending plugin data to backend, count:", pluginsToSend.length);
|
||||||
|
console.log("Using repositories:", repositoriesToCheck);
|
||||||
|
console.log("Sample plugin data:", pluginsToSend[0]);
|
||||||
|
|
||||||
|
const updatedPlugins = await invoke<Plugin[]>("check_plugin_updates", {
|
||||||
|
plugins: pluginsToSend,
|
||||||
|
repositories: repositoriesToCheck,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Bulk update check completed successfully, updating state.");
|
||||||
|
console.log(`Received ${updatedPlugins.length} updated plugins from backend`);
|
||||||
|
|
||||||
|
// Ensure up-to-date plugins have a latest_version
|
||||||
|
const processedPlugins = updatedPlugins.map(plugin => {
|
||||||
|
if (!plugin.latest_version && !plugin.has_update) {
|
||||||
|
return {
|
||||||
|
...plugin,
|
||||||
|
latest_version: plugin.version
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return plugin;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPlugins(processedPlugins);
|
||||||
|
|
||||||
|
// Emit an update check complete event if not emitted by backend
|
||||||
|
let updatedCount = processedPlugins.filter(p => p.has_update).length;
|
||||||
|
console.log(`Update check complete: ${updatedCount} plugins need updates`);
|
||||||
|
|
||||||
|
// Send a custom update_check_complete event to ensure the UI is updated
|
||||||
|
const event = new CustomEvent('update_check_complete', {
|
||||||
|
detail: {
|
||||||
|
outdated_count: updatedCount,
|
||||||
|
total_checked: processedPlugins.length,
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
if (currentServerPath) {
|
||||||
|
try {
|
||||||
|
console.log("[checkForUpdates] Saving plugin data...");
|
||||||
|
await invoke("save_plugin_data", { plugins: processedPlugins, serverPath: currentServerPath });
|
||||||
|
console.log("[checkForUpdates] Plugin data saved successfully.");
|
||||||
|
} catch (saveError) {
|
||||||
|
console.error("Error saving plugin data after bulk update:", saveError);
|
||||||
|
setUpdateError(`Update check complete, but failed to save plugin data: ${saveError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking for updates:", error);
|
||||||
|
setUpdateError(`Failed to check for updates: ${error}`);
|
||||||
|
} finally {
|
||||||
|
setIsCheckingUpdates(false);
|
||||||
|
setBulkUpdateProgress(null);
|
||||||
|
}
|
||||||
|
}, [plugins, isCheckingUpdates, serverPath, setPlugins, serverType]);
|
||||||
|
|
||||||
|
// Check for updates for a single plugin
|
||||||
|
const checkSinglePlugin = useCallback(async (plugin: Plugin) => {
|
||||||
|
const currentServerPath = serverPath;
|
||||||
|
console.log(`checkSinglePlugin called with serverPath: ${currentServerPath}`);
|
||||||
|
|
||||||
|
if (!currentServerPath) {
|
||||||
|
console.error('No server path available for checking single plugin');
|
||||||
|
setUpdateError('No server path available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCheckingSinglePlugin(true);
|
||||||
|
setUpdateError(null);
|
||||||
|
setPluginLoadingStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[plugin.file_path]: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Checking for updates for plugin: ${plugin.name}`);
|
||||||
|
|
||||||
|
// Use all lowercase repository names to match backend expectations
|
||||||
|
const repositories = ["hangarmc", "spigotmc", "modrinth", "github"];
|
||||||
|
|
||||||
|
console.log(`Repositories to check: ${repositories.join(', ')}`);
|
||||||
|
|
||||||
|
await invoke("check_single_plugin_update_command", {
|
||||||
|
plugin: plugin,
|
||||||
|
repositories: repositories
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Single plugin update check initiated for ${plugin.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error checking plugin ${plugin.name} for updates:`, err);
|
||||||
|
setUpdateError(`Failed to check ${plugin.name} for updates: ${err}`);
|
||||||
|
|
||||||
|
// Clear loading state for this plugin
|
||||||
|
setPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[plugin.file_path];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingSinglePlugin(false);
|
||||||
|
}
|
||||||
|
}, [serverPath]);
|
||||||
|
|
||||||
|
// Update a plugin to the latest version
|
||||||
|
const updatePlugin = useCallback(async (plugin: Plugin) => {
|
||||||
|
const currentServerPath = serverPath;
|
||||||
|
const currentServerType = serverType;
|
||||||
|
|
||||||
|
console.log(`updatePlugin called with serverPath: ${currentServerPath}`);
|
||||||
|
|
||||||
|
if (!canUpdatePlugin(plugin)) {
|
||||||
|
setUpdateError(`Cannot update ${plugin.name}: Missing required update information`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentServerPath) {
|
||||||
|
console.error('No server path available for updating plugin');
|
||||||
|
setUpdateError('No server path available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set loading state for this plugin
|
||||||
|
setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: true }));
|
||||||
|
setUpdateError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Updating plugin: ${plugin.name} to version ${plugin.latest_version}`);
|
||||||
|
|
||||||
|
// Create update message
|
||||||
|
const updateMessage = createUpdateMessage(
|
||||||
|
plugin.name,
|
||||||
|
plugin.latest_version,
|
||||||
|
plugin.platform_compatibility
|
||||||
|
);
|
||||||
|
|
||||||
|
// This is simplified as the actual UI feedback would be handled by the UIContext
|
||||||
|
console.log(updateMessage);
|
||||||
|
|
||||||
|
const newFilePath = await invoke<string>("update_plugin", {
|
||||||
|
pluginId: plugin.repository_id,
|
||||||
|
version: plugin.latest_version,
|
||||||
|
repository: plugin.repository_source,
|
||||||
|
currentFilePath: plugin.file_path,
|
||||||
|
serverTypeStr: currentServerType
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Update successful for ${plugin.name}, new file path: ${newFilePath}`);
|
||||||
|
|
||||||
|
// Update the plugins array with the updated plugin
|
||||||
|
const updatedPlugins = plugins.map(p => {
|
||||||
|
if (p.file_path === plugin.file_path) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
version: p.latest_version || p.version,
|
||||||
|
has_update: false,
|
||||||
|
latest_version: p.latest_version,
|
||||||
|
file_path: newFilePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPluginsState(updatedPlugins);
|
||||||
|
|
||||||
|
// Save updated plugins data
|
||||||
|
if (currentServerPath) {
|
||||||
|
try {
|
||||||
|
await invoke("save_plugin_data", {
|
||||||
|
plugins: updatedPlugins,
|
||||||
|
serverPath: currentServerPath
|
||||||
|
});
|
||||||
|
console.log(`Plugin data saved successfully after updating ${plugin.name}`);
|
||||||
|
} catch (saveError) {
|
||||||
|
console.error(`Error saving plugin data after update: ${saveError}`);
|
||||||
|
setUpdateError(`Plugin updated, but failed to save plugin data: ${saveError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch custom event for plugin update completed
|
||||||
|
const event = new CustomEvent('single_plugin_updated', {
|
||||||
|
detail: {
|
||||||
|
plugin_name: plugin.name,
|
||||||
|
old_path: plugin.file_path,
|
||||||
|
new_path: newFilePath,
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating plugin: ${error}`);
|
||||||
|
setUpdateError(`Failed to update ${plugin.name}: ${error}`);
|
||||||
|
|
||||||
|
// Dispatch custom event for plugin update failed
|
||||||
|
const event = new CustomEvent('single_plugin_update_failed', {
|
||||||
|
detail: {
|
||||||
|
plugin_name: plugin.name,
|
||||||
|
error: String(error)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
} finally {
|
||||||
|
// Clear loading state for this plugin
|
||||||
|
setPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[plugin.file_path];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [plugins, serverPath, serverType]);
|
||||||
|
|
||||||
|
// Show plugin details
|
||||||
|
const showPluginDetails = useCallback((plugin: Plugin) => {
|
||||||
|
setSelectedPlugin(plugin);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close plugin details
|
||||||
|
const closePluginDetails = useCallback(() => {
|
||||||
|
setSelectedPlugin(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Define the context value
|
||||||
|
const contextValue: PluginContextProps = {
|
||||||
|
plugins,
|
||||||
|
selectedPlugin,
|
||||||
|
isCheckingUpdates,
|
||||||
|
updateError,
|
||||||
|
pluginLoadingStates,
|
||||||
|
bulkUpdateProgress,
|
||||||
|
isCheckingSinglePlugin,
|
||||||
|
checkForUpdates,
|
||||||
|
checkSinglePlugin,
|
||||||
|
updatePlugin,
|
||||||
|
showPluginDetails,
|
||||||
|
closePluginDetails,
|
||||||
|
setPlugins,
|
||||||
|
clearUpdateError
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PluginContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</PluginContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginProvider;
|
20
src/context/PluginContext/usePluginContext.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { PluginContext } from './PluginContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for accessing the PluginContext
|
||||||
|
*
|
||||||
|
* @returns The PluginContext values and methods
|
||||||
|
* @throws Error if used outside of a PluginProvider
|
||||||
|
*/
|
||||||
|
export const usePluginContext = () => {
|
||||||
|
const context = useContext(PluginContext);
|
||||||
|
|
||||||
|
if (!context || Object.keys(context).length === 0) {
|
||||||
|
throw new Error('usePluginContext must be used within a PluginProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePluginContext;
|
246
src/context/ServerContext/ServerContext.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import React, { createContext, useState, useCallback, ReactNode, useEffect } from 'react';
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { ServerInfo, ScanProgress, ScanResult } from '../../types/server.types';
|
||||||
|
import { Plugin } from '../../types/plugin.types';
|
||||||
|
import { isValidPath } from '../../utils/validators';
|
||||||
|
|
||||||
|
interface ServerContextProps {
|
||||||
|
/**
|
||||||
|
* Current server directory path
|
||||||
|
*/
|
||||||
|
serverPath: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server information (type, version, etc.)
|
||||||
|
*/
|
||||||
|
serverInfo: ServerInfo | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a server scan is in progress
|
||||||
|
*/
|
||||||
|
isScanning: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a scan has been completed
|
||||||
|
*/
|
||||||
|
scanComplete: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message if any operation fails
|
||||||
|
*/
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress information during scanning
|
||||||
|
*/
|
||||||
|
scanProgress: ScanProgress | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to select a server directory
|
||||||
|
*/
|
||||||
|
selectDirectory: () => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to scan for plugins in the selected directory
|
||||||
|
*/
|
||||||
|
scanForPlugins: () => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to set the server path directly
|
||||||
|
*/
|
||||||
|
setServerPath: (path: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to clear any errors
|
||||||
|
*/
|
||||||
|
clearError: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to get the path to the plugins directory
|
||||||
|
*/
|
||||||
|
getPluginsPath: () => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the context with default values
|
||||||
|
export const ServerContext = createContext<ServerContextProps>({} as ServerContextProps);
|
||||||
|
|
||||||
|
interface ServerProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
onScanComplete?: (result: ScanResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider component for managing server-related state
|
||||||
|
*/
|
||||||
|
export const ServerProvider: React.FC<ServerProviderProps> = ({
|
||||||
|
children,
|
||||||
|
onScanComplete
|
||||||
|
}) => {
|
||||||
|
const [serverPath, setServerPathState] = useState<string>("");
|
||||||
|
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||||
|
const [isScanning, setIsScanning] = useState<boolean>(false);
|
||||||
|
const [scanComplete, setScanComplete] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [scanProgress, setScanProgress] = useState<ScanProgress | null>(null);
|
||||||
|
|
||||||
|
// Setup event listeners for scan events
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisteners: (() => void)[] = [];
|
||||||
|
|
||||||
|
// Handle scan-started event
|
||||||
|
listen('scan_started', () => {
|
||||||
|
console.log("Received scan_started event in ServerContext");
|
||||||
|
setIsScanning(true);
|
||||||
|
setScanComplete(false);
|
||||||
|
setScanProgress(null);
|
||||||
|
setError(null);
|
||||||
|
}).then(unlisten => unlisteners.push(unlisten));
|
||||||
|
|
||||||
|
// Handle scan-progress event
|
||||||
|
listen('scan_progress', (event) => {
|
||||||
|
console.log("Received scan_progress event in ServerContext:", event.payload);
|
||||||
|
// Map backend field names to frontend field names
|
||||||
|
const progressData = event.payload as { processed: number; total: number; current_file: string };
|
||||||
|
setScanProgress({
|
||||||
|
current: progressData.processed,
|
||||||
|
total: progressData.total,
|
||||||
|
current_file: progressData.current_file
|
||||||
|
});
|
||||||
|
}).then(unlisten => unlisteners.push(unlisten));
|
||||||
|
|
||||||
|
// Handle scan-completed event
|
||||||
|
listen('scan_completed', (event) => {
|
||||||
|
console.log("Received scan_completed event in ServerContext:", event.payload);
|
||||||
|
const result = event.payload as ScanResult;
|
||||||
|
|
||||||
|
setServerInfo(result.server_info);
|
||||||
|
setIsScanning(false);
|
||||||
|
setScanComplete(true);
|
||||||
|
|
||||||
|
// Call the callback if provided
|
||||||
|
if (onScanComplete) {
|
||||||
|
onScanComplete(result);
|
||||||
|
}
|
||||||
|
}).then(unlisten => unlisteners.push(unlisten));
|
||||||
|
|
||||||
|
// Handle scan-error event
|
||||||
|
listen('scan_error', (event) => {
|
||||||
|
console.log("Received scan_error event in ServerContext:", event.payload);
|
||||||
|
setError(event.payload as string);
|
||||||
|
setIsScanning(false);
|
||||||
|
}).then(unlisten => unlisteners.push(unlisten));
|
||||||
|
|
||||||
|
// Cleanup function to remove event listeners
|
||||||
|
return () => {
|
||||||
|
unlisteners.forEach(unlisten => unlisten());
|
||||||
|
};
|
||||||
|
}, [onScanComplete]);
|
||||||
|
|
||||||
|
// Set server path
|
||||||
|
const setServerPath = useCallback((path: string) => {
|
||||||
|
setServerPathState(path);
|
||||||
|
setServerInfo(null);
|
||||||
|
setScanComplete(false);
|
||||||
|
setError(null);
|
||||||
|
setScanProgress(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear error state
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Function to select a server directory using Tauri dialog
|
||||||
|
const selectDirectory = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
title: "Select Minecraft Server Folder",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === "string") {
|
||||||
|
console.log(`Directory selected: ${selected}`);
|
||||||
|
setServerPath(selected);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load persisted plugin data if available
|
||||||
|
console.log(`Attempting to load persisted data for: ${selected}`);
|
||||||
|
const loadedPlugins: Plugin[] = await invoke("load_plugin_data", { serverPath: selected });
|
||||||
|
|
||||||
|
if (loadedPlugins && loadedPlugins.length > 0) {
|
||||||
|
console.log(`Loaded ${loadedPlugins.length} plugins from persistence.`);
|
||||||
|
// We don't set plugins here as that's handled by the PluginContext
|
||||||
|
setScanComplete(true);
|
||||||
|
} else {
|
||||||
|
console.log("No persisted plugin data found for this server.");
|
||||||
|
}
|
||||||
|
} catch (loadError) {
|
||||||
|
console.error("Error loading persisted plugin data:", loadError);
|
||||||
|
setError(`Failed to load previous plugin data: ${loadError}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Directory selection cancelled.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error selecting directory:", err);
|
||||||
|
setError(`Error selecting directory: ${err}`);
|
||||||
|
}
|
||||||
|
}, [setServerPath]);
|
||||||
|
|
||||||
|
// Function to scan for plugins in the selected directory
|
||||||
|
const scanForPlugins = useCallback(async () => {
|
||||||
|
if (!isValidPath(serverPath) || isScanning) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Starting scan for plugins in:", serverPath);
|
||||||
|
setIsScanning(true);
|
||||||
|
setScanComplete(false);
|
||||||
|
setScanProgress(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await invoke("scan_server_dir", { path: serverPath });
|
||||||
|
console.log("Scan server dir command invoked successfully");
|
||||||
|
|
||||||
|
// Note: The actual scan results will be received via event listeners
|
||||||
|
// which are now set up in the useEffect hook above
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error invoking scan command:", err);
|
||||||
|
setError(`Failed to start scan: ${err as string}`);
|
||||||
|
setIsScanning(false);
|
||||||
|
}
|
||||||
|
}, [serverPath, isScanning]);
|
||||||
|
|
||||||
|
// Function to get the plugins directory path
|
||||||
|
const getPluginsPath = useCallback((): string | null => {
|
||||||
|
if (!serverInfo || !serverPath) return null;
|
||||||
|
|
||||||
|
return `${serverPath}/${serverInfo.plugins_directory}`;
|
||||||
|
}, [serverPath, serverInfo]);
|
||||||
|
|
||||||
|
// Define the context value
|
||||||
|
const contextValue: ServerContextProps = {
|
||||||
|
serverPath,
|
||||||
|
serverInfo,
|
||||||
|
isScanning,
|
||||||
|
scanComplete,
|
||||||
|
error,
|
||||||
|
scanProgress,
|
||||||
|
selectDirectory,
|
||||||
|
scanForPlugins,
|
||||||
|
setServerPath: setServerPathState,
|
||||||
|
clearError,
|
||||||
|
getPluginsPath
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ServerContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</ServerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerProvider;
|
20
src/context/ServerContext/useServerContext.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { ServerContext } from './ServerContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for accessing the ServerContext
|
||||||
|
*
|
||||||
|
* @returns The ServerContext values and methods
|
||||||
|
* @throws Error if used outside of a ServerProvider
|
||||||
|
*/
|
||||||
|
export const useServerContext = () => {
|
||||||
|
const context = useContext(ServerContext);
|
||||||
|
|
||||||
|
if (!context || Object.keys(context).length === 0) {
|
||||||
|
throw new Error('useServerContext must be used within a ServerProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useServerContext;
|
150
src/context/UIContext/UIContext.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import React, { createContext, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { Plugin } from '../../types/plugin.types';
|
||||||
|
|
||||||
|
export type MessageType = 'info' | 'warning' | 'error' | 'success';
|
||||||
|
|
||||||
|
export interface WarningMessage {
|
||||||
|
text: string;
|
||||||
|
type: MessageType;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PremiumPluginInfo {
|
||||||
|
name: string;
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UIContextProps {
|
||||||
|
warningMessage: WarningMessage | null;
|
||||||
|
premiumPluginInfo: PremiumPluginInfo | null;
|
||||||
|
downloadProgress: number;
|
||||||
|
isCompatibilityDialogOpen: boolean;
|
||||||
|
currentPluginForCompatibility: Plugin | null;
|
||||||
|
isUpdateAvailable: boolean;
|
||||||
|
updateVersion: string | null;
|
||||||
|
showWarningMessage: (text: string, type: MessageType) => void;
|
||||||
|
clearWarningMessage: () => void;
|
||||||
|
handlePremiumPlugin: (errorMessage: string, pluginName: string) => void;
|
||||||
|
clearPremiumPluginInfo: () => void;
|
||||||
|
setDownloadProgress: (progress: number) => void;
|
||||||
|
showCompatibilityDialog: (plugin: Plugin) => void;
|
||||||
|
closeCompatibilityDialog: () => void;
|
||||||
|
showUpdateAvailableMessage: (version: string) => void;
|
||||||
|
clearUpdateAvailableMessage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UIContext = createContext<UIContextProps | null>(null);
|
||||||
|
|
||||||
|
interface UIProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageIdCounter = 0;
|
||||||
|
|
||||||
|
export const UIProvider: React.FC<UIProviderProps> = ({ children }) => {
|
||||||
|
// State for warning messages
|
||||||
|
const [warningMessage, setWarningMessage] = useState<WarningMessage | null>(null);
|
||||||
|
|
||||||
|
// State for premium plugin info
|
||||||
|
const [premiumPluginInfo, setPremiumPluginInfo] = useState<PremiumPluginInfo | null>(null);
|
||||||
|
|
||||||
|
// State for download progress
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState<number>(0);
|
||||||
|
|
||||||
|
// State for compatibility dialog
|
||||||
|
const [isCompatibilityDialogOpen, setIsCompatibilityDialogOpen] = useState<boolean>(false);
|
||||||
|
const [currentPluginForCompatibility, setCurrentPluginForCompatibility] = useState<Plugin | null>(null);
|
||||||
|
|
||||||
|
// State for update available notification
|
||||||
|
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false);
|
||||||
|
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Function to show a warning message
|
||||||
|
const showWarningMessage = useCallback((text: string, type: MessageType = 'warning') => {
|
||||||
|
setWarningMessage({
|
||||||
|
text,
|
||||||
|
type,
|
||||||
|
id: messageIdCounter++
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-clear messages after 5 seconds except for errors
|
||||||
|
if (type !== 'error') {
|
||||||
|
setTimeout(() => {
|
||||||
|
setWarningMessage(prevMessage =>
|
||||||
|
prevMessage && prevMessage.id === messageIdCounter - 1 ? null : prevMessage
|
||||||
|
);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Function to clear the warning message
|
||||||
|
const clearWarningMessage = useCallback(() => {
|
||||||
|
setWarningMessage(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Function to handle premium plugin errors
|
||||||
|
const handlePremiumPlugin = useCallback((errorMessage: string, pluginName: string) => {
|
||||||
|
setPremiumPluginInfo({
|
||||||
|
name: pluginName,
|
||||||
|
errorMessage
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Function to clear premium plugin info
|
||||||
|
const clearPremiumPluginInfo = useCallback(() => {
|
||||||
|
setPremiumPluginInfo(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Function to show compatibility dialog
|
||||||
|
const showCompatibilityDialog = useCallback((plugin: Plugin) => {
|
||||||
|
setCurrentPluginForCompatibility(plugin);
|
||||||
|
setIsCompatibilityDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Function to close compatibility dialog
|
||||||
|
const closeCompatibilityDialog = useCallback(() => {
|
||||||
|
setIsCompatibilityDialogOpen(false);
|
||||||
|
setCurrentPluginForCompatibility(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Function to show update available message
|
||||||
|
const showUpdateAvailableMessage = useCallback((version: string) => {
|
||||||
|
setUpdateVersion(version);
|
||||||
|
setIsUpdateAvailable(true);
|
||||||
|
showWarningMessage(`Update available: version ${version}`, 'info');
|
||||||
|
}, [showWarningMessage]);
|
||||||
|
|
||||||
|
// Function to clear update available message
|
||||||
|
const clearUpdateAvailableMessage = useCallback(() => {
|
||||||
|
setIsUpdateAvailable(false);
|
||||||
|
setUpdateVersion(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Context value
|
||||||
|
const value: UIContextProps = {
|
||||||
|
warningMessage,
|
||||||
|
premiumPluginInfo,
|
||||||
|
downloadProgress,
|
||||||
|
isCompatibilityDialogOpen,
|
||||||
|
currentPluginForCompatibility,
|
||||||
|
isUpdateAvailable,
|
||||||
|
updateVersion,
|
||||||
|
showWarningMessage,
|
||||||
|
clearWarningMessage,
|
||||||
|
handlePremiumPlugin,
|
||||||
|
clearPremiumPluginInfo,
|
||||||
|
setDownloadProgress,
|
||||||
|
showCompatibilityDialog,
|
||||||
|
closeCompatibilityDialog,
|
||||||
|
showUpdateAvailableMessage,
|
||||||
|
clearUpdateAvailableMessage
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UIContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</UIContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UIProvider;
|
20
src/context/UIContext/useUIContext.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import { UIContext } from './UIContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for accessing the UIContext
|
||||||
|
*
|
||||||
|
* @returns The UIContext values and methods
|
||||||
|
* @throws Error if used outside of a UIProvider
|
||||||
|
*/
|
||||||
|
export const useUIContext = () => {
|
||||||
|
const context = useContext(UIContext);
|
||||||
|
|
||||||
|
if (!context || Object.keys(context).length === 0) {
|
||||||
|
throw new Error('useUIContext must be used within a UIProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUIContext;
|
59
src/hooks/useAppUpdates.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useUIContext } from '../context/UIContext/useUIContext';
|
||||||
|
import { checkForAppUpdate as checkForAppUpdateAPI, UpdateInfo } from '../utils/appUpdates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing application updates
|
||||||
|
*/
|
||||||
|
export const useAppUpdates = () => {
|
||||||
|
const [isCheckingAppUpdate, setIsCheckingAppUpdate] = useState<boolean>(false);
|
||||||
|
const [appUpdateAvailable, setAppUpdateAvailable] = useState<boolean>(false);
|
||||||
|
const [appUpdateVersion, setAppUpdateVersion] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { showUpdateAvailableMessage } = useUIContext();
|
||||||
|
|
||||||
|
// Check for application updates
|
||||||
|
const checkForAppUpdate = useCallback(async () => {
|
||||||
|
if (isCheckingAppUpdate) return;
|
||||||
|
|
||||||
|
setIsCheckingAppUpdate(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Checking for application updates...');
|
||||||
|
const updateInfo: UpdateInfo = await checkForAppUpdateAPI();
|
||||||
|
|
||||||
|
setAppUpdateAvailable(updateInfo.available);
|
||||||
|
setAppUpdateVersion(updateInfo.version || null);
|
||||||
|
|
||||||
|
if (updateInfo.available && updateInfo.version) {
|
||||||
|
console.log(`Application update available: ${updateInfo.version}`);
|
||||||
|
showUpdateAvailableMessage(updateInfo.version);
|
||||||
|
} else {
|
||||||
|
console.log('No application updates available');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for app updates:', error);
|
||||||
|
} finally {
|
||||||
|
setIsCheckingAppUpdate(false);
|
||||||
|
}
|
||||||
|
}, [isCheckingAppUpdate, showUpdateAvailableMessage]);
|
||||||
|
|
||||||
|
// Auto-check for updates on initial load
|
||||||
|
useEffect(() => {
|
||||||
|
// Wait a moment after initial load before checking
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
checkForAppUpdate();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [checkForAppUpdate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCheckingAppUpdate,
|
||||||
|
appUpdateAvailable,
|
||||||
|
appUpdateVersion,
|
||||||
|
checkForAppUpdate
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAppUpdates;
|
215
src/hooks/useEventListeners.ts
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { listen, UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
// Import types
|
||||||
|
import { ScanProgress, ScanResult } from '../types/server.types';
|
||||||
|
import { BulkUpdateProgressPayload, SingleUpdateResultPayload, DownloadProgress, PremiumPluginInfo } from '../types/events.types';
|
||||||
|
import { Plugin } from '../types/plugin.types';
|
||||||
|
|
||||||
|
// Event handler types
|
||||||
|
export type ScanStartedHandler = (serverPath: string) => void;
|
||||||
|
export type ScanProgressHandler = (progress: { current: number; total: number }) => void;
|
||||||
|
export type ScanCompletedHandler = (result: {
|
||||||
|
plugins: any[];
|
||||||
|
server_info: any;
|
||||||
|
}) => void;
|
||||||
|
export type ScanErrorHandler = (error: string) => void;
|
||||||
|
|
||||||
|
export type BulkUpdateStartHandler = (totalPlugins: number) => void;
|
||||||
|
export type UpdateCheckProgressHandler = (progress: {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
plugin_name?: string;
|
||||||
|
}) => void;
|
||||||
|
export type SingleUpdateStartedHandler = (pluginName: string) => void;
|
||||||
|
export type SingleUpdateCompletedHandler = (result: {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
plugin_name?: string;
|
||||||
|
original_file_path?: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type DownloadProgressHandler = (progress: {
|
||||||
|
percentage: number;
|
||||||
|
pluginName?: string;
|
||||||
|
status: 'downloading' | 'completed' | 'error';
|
||||||
|
error?: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type UpdateCheckCompleteHandler = (result: {
|
||||||
|
outdated_count: number;
|
||||||
|
total_checked: number;
|
||||||
|
success: boolean;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type AppUpdateAvailableHandler = (info: {
|
||||||
|
version: string;
|
||||||
|
body?: string;
|
||||||
|
date?: string;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export interface EventListenerProps {
|
||||||
|
// Scan events
|
||||||
|
onScanStarted?: ScanStartedHandler;
|
||||||
|
onScanProgress?: ScanProgressHandler;
|
||||||
|
onScanCompleted?: ScanCompletedHandler;
|
||||||
|
onScanError?: ScanErrorHandler;
|
||||||
|
|
||||||
|
// Update check events
|
||||||
|
onBulkUpdateStart?: BulkUpdateStartHandler;
|
||||||
|
onUpdateCheckProgress?: UpdateCheckProgressHandler;
|
||||||
|
onSingleUpdateStarted?: SingleUpdateStartedHandler;
|
||||||
|
onSingleUpdateCompleted?: SingleUpdateCompletedHandler;
|
||||||
|
onDownloadProgress?: DownloadProgressHandler;
|
||||||
|
onUpdateCheckComplete?: UpdateCheckCompleteHandler;
|
||||||
|
|
||||||
|
// App update events
|
||||||
|
onAppUpdateAvailable?: AppUpdateAvailableHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for setting up all the Tauri event listeners
|
||||||
|
*
|
||||||
|
* @param handlers Object containing event handler functions
|
||||||
|
*/
|
||||||
|
export const useEventListeners = ({
|
||||||
|
onScanStarted,
|
||||||
|
onScanProgress,
|
||||||
|
onScanCompleted,
|
||||||
|
onScanError,
|
||||||
|
onBulkUpdateStart,
|
||||||
|
onUpdateCheckProgress,
|
||||||
|
onSingleUpdateStarted,
|
||||||
|
onSingleUpdateCompleted,
|
||||||
|
onDownloadProgress,
|
||||||
|
onUpdateCheckComplete,
|
||||||
|
onAppUpdateAvailable
|
||||||
|
}: EventListenerProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenFunctions: UnlistenFn[] = [];
|
||||||
|
|
||||||
|
// Set up scan event listeners
|
||||||
|
if (onScanStarted) {
|
||||||
|
listen('scan_started', (event) => {
|
||||||
|
onScanStarted(event.payload as string);
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onScanProgress) {
|
||||||
|
listen('scan_progress', (event) => {
|
||||||
|
onScanProgress(event.payload as { current: number; total: number });
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onScanCompleted) {
|
||||||
|
listen('scan_completed', (event) => {
|
||||||
|
onScanCompleted(event.payload as { plugins: any[]; server_info: any });
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onScanError) {
|
||||||
|
listen('scan_error', (event) => {
|
||||||
|
onScanError(event.payload as string);
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up update check event listeners
|
||||||
|
if (onBulkUpdateStart) {
|
||||||
|
listen('bulk_update_start', (event) => {
|
||||||
|
onBulkUpdateStart(event.payload as number);
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onUpdateCheckProgress) {
|
||||||
|
listen('update_check_progress', (event) => {
|
||||||
|
onUpdateCheckProgress(event.payload as {
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
plugin_name?: string;
|
||||||
|
});
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSingleUpdateStarted) {
|
||||||
|
listen('single_update_started', (event) => {
|
||||||
|
onSingleUpdateStarted(event.payload as string);
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSingleUpdateCompleted) {
|
||||||
|
listen('single_update_completed', (event) => {
|
||||||
|
onSingleUpdateCompleted(event.payload as {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
plugin_name?: string;
|
||||||
|
original_file_path?: string;
|
||||||
|
});
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onDownloadProgress) {
|
||||||
|
listen('download_progress', (event) => {
|
||||||
|
onDownloadProgress(event.payload as {
|
||||||
|
percentage: number;
|
||||||
|
pluginName?: string;
|
||||||
|
status: 'downloading' | 'completed' | 'error';
|
||||||
|
error?: string;
|
||||||
|
});
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onUpdateCheckComplete) {
|
||||||
|
// Listen for Tauri event
|
||||||
|
listen('update_check_complete', (event) => {
|
||||||
|
console.log("Received Tauri update_check_complete event:", event.payload);
|
||||||
|
onUpdateCheckComplete(event.payload as {
|
||||||
|
outdated_count: number;
|
||||||
|
total_checked: number;
|
||||||
|
success: boolean;
|
||||||
|
});
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
|
||||||
|
// Also listen for custom browser event (fallback)
|
||||||
|
const handleCustomEvent = (event: any) => {
|
||||||
|
console.log("Received custom update_check_complete event:", event.detail);
|
||||||
|
onUpdateCheckComplete(event.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('update_check_complete', handleCustomEvent);
|
||||||
|
|
||||||
|
// Add cleanup function for browser event
|
||||||
|
unlistenFunctions.push(() => {
|
||||||
|
window.removeEventListener('update_check_complete', handleCustomEvent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onAppUpdateAvailable) {
|
||||||
|
listen('update_available', (event) => {
|
||||||
|
onAppUpdateAvailable(event.payload as {
|
||||||
|
version: string;
|
||||||
|
body?: string;
|
||||||
|
date?: string;
|
||||||
|
});
|
||||||
|
}).then(unlisten => unlistenFunctions.push(unlisten));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up function to remove all listeners
|
||||||
|
return () => {
|
||||||
|
unlistenFunctions.forEach(unlisten => unlisten());
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
onScanStarted,
|
||||||
|
onScanProgress,
|
||||||
|
onScanCompleted,
|
||||||
|
onScanError,
|
||||||
|
onBulkUpdateStart,
|
||||||
|
onUpdateCheckProgress,
|
||||||
|
onSingleUpdateStarted,
|
||||||
|
onSingleUpdateCompleted,
|
||||||
|
onDownloadProgress,
|
||||||
|
onUpdateCheckComplete,
|
||||||
|
onAppUpdateAvailable
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useEventListeners;
|
418
src/hooks/usePluginActions.ts
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
import { useCallback, useState, useEffect, useMemo } from 'react';
|
||||||
|
import { usePluginContext } from '../context/PluginContext/usePluginContext';
|
||||||
|
import { useUIContext } from '../context/UIContext/useUIContext';
|
||||||
|
import { useEventListeners } from './useEventListeners';
|
||||||
|
import { Plugin, PotentialPluginMatch } from '../types/plugin.types';
|
||||||
|
import { ServerType } from '../types/server.types';
|
||||||
|
import { isPremiumPluginError } from '../utils/validators';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
// Define the potential matches payload type
|
||||||
|
interface PotentialMatchesPayload {
|
||||||
|
plugin: {
|
||||||
|
file_path: string;
|
||||||
|
};
|
||||||
|
matches: PotentialPluginMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook that extends PluginContext with enhanced plugin actions
|
||||||
|
* and automatic event handling
|
||||||
|
*/
|
||||||
|
export const usePluginActions = (serverType?: ServerType) => {
|
||||||
|
const {
|
||||||
|
plugins,
|
||||||
|
selectedPlugin,
|
||||||
|
isCheckingUpdates,
|
||||||
|
updateError,
|
||||||
|
pluginLoadingStates,
|
||||||
|
bulkUpdateProgress,
|
||||||
|
isCheckingSinglePlugin,
|
||||||
|
checkForUpdates: contextCheckForUpdates,
|
||||||
|
checkSinglePlugin: contextCheckSinglePlugin,
|
||||||
|
updatePlugin: contextUpdatePlugin,
|
||||||
|
showPluginDetails,
|
||||||
|
closePluginDetails,
|
||||||
|
setPlugins,
|
||||||
|
clearUpdateError
|
||||||
|
} = usePluginContext();
|
||||||
|
|
||||||
|
const {
|
||||||
|
showCompatibilityDialog,
|
||||||
|
handlePremiumPlugin,
|
||||||
|
showWarningMessage
|
||||||
|
} = useUIContext();
|
||||||
|
|
||||||
|
// Local state to track updates in progress
|
||||||
|
const [updatingPlugin, setUpdatingPlugin] = useState<string | null>(null);
|
||||||
|
const [currentUpdateProgress, setCurrentUpdateProgress] = useState<number>(0);
|
||||||
|
|
||||||
|
// Add state for potential matches
|
||||||
|
const [potentialMatches, setPotentialMatches] = useState<PotentialPluginMatch[]>([]);
|
||||||
|
const [currentPluginForMatch, setCurrentPluginForMatch] = useState<Plugin | null>(null);
|
||||||
|
const [isMatchSelectorOpen, setIsMatchSelectorOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Create a state object for plugin loading states that we manage locally
|
||||||
|
const [localPluginLoadingStates, setLocalPluginLoadingStates] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// Enhanced check for updates function
|
||||||
|
const checkForUpdates = useCallback(async () => {
|
||||||
|
await contextCheckForUpdates(serverType);
|
||||||
|
}, [contextCheckForUpdates, serverType]);
|
||||||
|
|
||||||
|
// Enhanced function for managing loading states
|
||||||
|
const checkSinglePlugin = useCallback(async (plugin: Plugin) => {
|
||||||
|
// First, check if the plugin is already being checked
|
||||||
|
if (pluginLoadingStates[plugin.file_path] || localPluginLoadingStates[plugin.file_path]) {
|
||||||
|
console.log(`Plugin ${plugin.name} is already being checked for updates, skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set loading state before checking update
|
||||||
|
setLocalPluginLoadingStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[plugin.file_path]: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Checking plugin ${plugin.name} for updates`);
|
||||||
|
await contextCheckSinglePlugin(plugin);
|
||||||
|
// The loading state will be cleared by the event listener when update check completes
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking plugin ${plugin.name} for updates:`, error);
|
||||||
|
showWarningMessage(`Failed to check ${plugin.name} for updates: ${error}`, 'error');
|
||||||
|
|
||||||
|
// Clear loading state on error
|
||||||
|
setLocalPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[plugin.file_path];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [contextCheckSinglePlugin, showWarningMessage, pluginLoadingStates, localPluginLoadingStates]);
|
||||||
|
|
||||||
|
// Enhanced update plugin function with compatibility check
|
||||||
|
const updatePlugin = useCallback((plugin: Plugin) => {
|
||||||
|
// Set loading state
|
||||||
|
setLocalPluginLoadingStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[plugin.file_path]: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Instead of updating directly, show compatibility dialog first
|
||||||
|
showCompatibilityDialog(plugin);
|
||||||
|
|
||||||
|
// Note: The actual update will happen when the user confirms in the dialog
|
||||||
|
// and the UIContext will call the updatePlugin function from PluginContext
|
||||||
|
// Loading state will be cleared by event listeners
|
||||||
|
}, [showCompatibilityDialog]);
|
||||||
|
|
||||||
|
// Function to proceed with update after compatibility check
|
||||||
|
const proceedWithUpdate = useCallback(async (plugin: Plugin) => {
|
||||||
|
try {
|
||||||
|
await contextUpdatePlugin(plugin);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error in proceedWithUpdate:', error);
|
||||||
|
|
||||||
|
// Handle premium plugin error
|
||||||
|
if (isPremiumPluginError(error.toString())) {
|
||||||
|
handlePremiumPlugin(error.toString(), plugin.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [contextUpdatePlugin, handlePremiumPlugin]);
|
||||||
|
|
||||||
|
// Listen for events
|
||||||
|
useEventListeners({
|
||||||
|
onBulkUpdateStart: (totalPlugins) => {
|
||||||
|
console.log('Bulk update start handled in usePluginActions:', totalPlugins);
|
||||||
|
// Context already handles this, no additional action needed
|
||||||
|
},
|
||||||
|
onUpdateCheckProgress: (progress) => {
|
||||||
|
console.log('Update check progress handled in usePluginActions:', progress);
|
||||||
|
// Context already handles this, no additional action needed
|
||||||
|
},
|
||||||
|
onSingleUpdateStarted: (pluginName) => {
|
||||||
|
console.log('Single update started handled in usePluginActions:', pluginName);
|
||||||
|
// Context already handles this, no additional action needed
|
||||||
|
},
|
||||||
|
onSingleUpdateCompleted: (result) => {
|
||||||
|
console.log('Single update completed handled in usePluginActions:', result);
|
||||||
|
const plugin = plugins.find(p => p.file_path === result.original_file_path);
|
||||||
|
const pluginName = plugin ? plugin.name : 'Plugin';
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
// If it's a premium plugin error, handle it specially
|
||||||
|
if (isPremiumPluginError(result.error)) {
|
||||||
|
if (plugin) {
|
||||||
|
handlePremiumPlugin(result.error, plugin.name);
|
||||||
|
// Optionally show a general warning too
|
||||||
|
showWarningMessage(`Could not update premium plugin: ${pluginName}. Requires manual download.`, 'warning');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show general error notification
|
||||||
|
showWarningMessage(`Failed to update ${pluginName}: ${result.error}`, 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show success notification
|
||||||
|
showWarningMessage(`${pluginName} updated successfully!`, 'success');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDownloadProgress: (progress) => {
|
||||||
|
console.log('Download progress handled in usePluginActions:', progress);
|
||||||
|
|
||||||
|
if (progress.pluginName) {
|
||||||
|
setUpdatingPlugin(progress.pluginName);
|
||||||
|
setCurrentUpdateProgress(progress.percentage);
|
||||||
|
|
||||||
|
if (progress.status === 'completed') {
|
||||||
|
// Reset after completion
|
||||||
|
setTimeout(() => {
|
||||||
|
setUpdatingPlugin(null);
|
||||||
|
setCurrentUpdateProgress(0);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for potential matches in a useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenPotentialMatches = listen<PotentialMatchesPayload>('potential_matches_found', (event: { payload: PotentialMatchesPayload }) => {
|
||||||
|
const payload = event.payload;
|
||||||
|
console.log('Potential matches found:', payload);
|
||||||
|
|
||||||
|
if (payload.matches && payload.matches.length > 0) {
|
||||||
|
// Find the plugin this is for
|
||||||
|
const targetPlugin = plugins.find(p => p.file_path === payload.plugin.file_path);
|
||||||
|
if (targetPlugin) {
|
||||||
|
setPotentialMatches(payload.matches);
|
||||||
|
setCurrentPluginForMatch(targetPlugin);
|
||||||
|
setIsMatchSelectorOpen(true);
|
||||||
|
|
||||||
|
// Clear loading state for this plugin
|
||||||
|
setLocalPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[payload.plugin.file_path];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenPotentialMatches.then((unlisten: () => void) => unlisten());
|
||||||
|
};
|
||||||
|
}, [plugins]);
|
||||||
|
|
||||||
|
// Function to handle selecting a match
|
||||||
|
const handleMatchSelection = useCallback(async (match: PotentialPluginMatch) => {
|
||||||
|
if (currentPluginForMatch) {
|
||||||
|
try {
|
||||||
|
// Set loading state for this plugin
|
||||||
|
setLocalPluginLoadingStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentPluginForMatch.file_path]: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Invoke backend to update with selected match
|
||||||
|
await invoke('set_plugin_match', {
|
||||||
|
pluginPath: currentPluginForMatch.file_path,
|
||||||
|
match: {
|
||||||
|
name: match.name,
|
||||||
|
repository: match.repository,
|
||||||
|
repositoryId: match.repository_id,
|
||||||
|
version: match.version,
|
||||||
|
pageUrl: match.page_url
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showWarningMessage(`Successfully matched ${currentPluginForMatch.name} with ${match.name} from ${match.repository}`, 'success');
|
||||||
|
|
||||||
|
// Refresh plugin data
|
||||||
|
checkSinglePlugin(currentPluginForMatch);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting plugin match:', error);
|
||||||
|
showWarningMessage(`Failed to set plugin match: ${error}`, 'error');
|
||||||
|
|
||||||
|
// Clear loading state
|
||||||
|
setLocalPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[currentPluginForMatch.file_path];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the selector
|
||||||
|
setIsMatchSelectorOpen(false);
|
||||||
|
setPotentialMatches([]);
|
||||||
|
setCurrentPluginForMatch(null);
|
||||||
|
}, [currentPluginForMatch, showWarningMessage, checkSinglePlugin]);
|
||||||
|
|
||||||
|
// Function to close match selector without selecting
|
||||||
|
const closeMatchSelector = useCallback(() => {
|
||||||
|
setIsMatchSelectorOpen(false);
|
||||||
|
setPotentialMatches([]);
|
||||||
|
setCurrentPluginForMatch(null);
|
||||||
|
|
||||||
|
// Clear loading state if there's a current plugin
|
||||||
|
if (currentPluginForMatch) {
|
||||||
|
setLocalPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[currentPluginForMatch.file_path];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentPluginForMatch]);
|
||||||
|
|
||||||
|
// Get outdated plugins count
|
||||||
|
const getOutdatedPluginsCount = useCallback((): number => {
|
||||||
|
return plugins.filter(plugin => plugin.has_update).length;
|
||||||
|
}, [plugins]);
|
||||||
|
|
||||||
|
// Get plugin by name
|
||||||
|
const getPluginByName = useCallback((name: string): Plugin | undefined => {
|
||||||
|
return plugins.find(plugin => plugin.name.toLowerCase() === name.toLowerCase());
|
||||||
|
}, [plugins]);
|
||||||
|
|
||||||
|
// Get plugins filtered by criteria
|
||||||
|
const getFilteredPlugins = useCallback((criteria: Partial<Plugin>): Plugin[] => {
|
||||||
|
return plugins.filter(plugin => {
|
||||||
|
return Object.entries(criteria).every(([key, value]) => {
|
||||||
|
return plugin[key as keyof Plugin] === value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [plugins]);
|
||||||
|
|
||||||
|
// Combined loading states from context and local state
|
||||||
|
const combinedLoadingStates = useMemo(() => {
|
||||||
|
return {
|
||||||
|
...localPluginLoadingStates,
|
||||||
|
...pluginLoadingStates
|
||||||
|
};
|
||||||
|
}, [localPluginLoadingStates, pluginLoadingStates]);
|
||||||
|
|
||||||
|
// Set up event listeners for plugin-related events
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisteners: (() => void)[] = [];
|
||||||
|
|
||||||
|
// Listen for bulk updates complete
|
||||||
|
window.addEventListener('update_check_complete', (event: any) => {
|
||||||
|
console.log('Custom update_check_complete event received:', event.detail);
|
||||||
|
|
||||||
|
if (event.detail?.success && event.detail?.outdated_count) {
|
||||||
|
const message = `Found ${event.detail.outdated_count} plugins with updates available.`;
|
||||||
|
showWarningMessage(message, 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for single plugin update completed
|
||||||
|
window.addEventListener('single_plugin_updated', (event: any) => {
|
||||||
|
console.log('Custom single_plugin_updated event received:', event.detail);
|
||||||
|
|
||||||
|
if (event.detail?.success) {
|
||||||
|
const message = `Successfully updated ${event.detail.plugin_name}.`;
|
||||||
|
showWarningMessage(message, 'success');
|
||||||
|
|
||||||
|
// Clear loading state for this plugin
|
||||||
|
setLocalPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
if (event.detail.old_path) {
|
||||||
|
delete newState[event.detail.old_path];
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for single plugin update failed
|
||||||
|
window.addEventListener('single_plugin_update_failed', (event: any) => {
|
||||||
|
console.log('Custom single_plugin_update_failed event received:', event.detail);
|
||||||
|
|
||||||
|
if (event.detail?.plugin_name) {
|
||||||
|
const message = `Failed to update ${event.detail.plugin_name}: ${event.detail.error || 'Unknown error'}`;
|
||||||
|
showWarningMessage(message, 'error');
|
||||||
|
|
||||||
|
// Clear loading state for this plugin
|
||||||
|
setLocalPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
if (event.detail.plugin_name) {
|
||||||
|
// Find the plugin by name and clear its loading state
|
||||||
|
const plugin = plugins.find(p => p.name === event.detail.plugin_name);
|
||||||
|
if (plugin?.file_path) {
|
||||||
|
delete newState[plugin.file_path];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for single plugin check completed
|
||||||
|
window.addEventListener('single_plugin_check_completed', (event: any) => {
|
||||||
|
console.log('Custom single_plugin_check_completed event received:', event.detail);
|
||||||
|
|
||||||
|
if (event.detail?.plugin_path) {
|
||||||
|
// Clear loading state for this plugin
|
||||||
|
setLocalPluginLoadingStates(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[event.detail.plugin_path];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show result notification
|
||||||
|
if (event.detail.error) {
|
||||||
|
showWarningMessage(
|
||||||
|
`Failed to check for updates: ${event.detail.error}`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Remove event listeners
|
||||||
|
window.removeEventListener('update_check_complete', () => {});
|
||||||
|
window.removeEventListener('single_plugin_updated', () => {});
|
||||||
|
window.removeEventListener('single_plugin_update_failed', () => {});
|
||||||
|
window.removeEventListener('single_plugin_check_completed', () => {});
|
||||||
|
};
|
||||||
|
}, [plugins, showWarningMessage]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Original context values
|
||||||
|
plugins,
|
||||||
|
selectedPlugin,
|
||||||
|
isCheckingUpdates,
|
||||||
|
updateError,
|
||||||
|
pluginLoadingStates: combinedLoadingStates,
|
||||||
|
bulkUpdateProgress,
|
||||||
|
isCheckingSinglePlugin,
|
||||||
|
showPluginDetails,
|
||||||
|
closePluginDetails,
|
||||||
|
setPlugins,
|
||||||
|
clearUpdateError,
|
||||||
|
|
||||||
|
// Enhanced functionality
|
||||||
|
updatingPlugin,
|
||||||
|
currentUpdateProgress,
|
||||||
|
checkForUpdates,
|
||||||
|
checkSinglePlugin,
|
||||||
|
updatePlugin,
|
||||||
|
proceedWithUpdate,
|
||||||
|
getOutdatedPluginsCount,
|
||||||
|
getPluginByName,
|
||||||
|
getFilteredPlugins,
|
||||||
|
|
||||||
|
// Add match selector state and handlers
|
||||||
|
potentialMatches,
|
||||||
|
currentPluginForMatch,
|
||||||
|
isMatchSelectorOpen,
|
||||||
|
handleMatchSelection,
|
||||||
|
closeMatchSelector
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePluginActions;
|
109
src/hooks/useServerActions.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useServerContext } from '../context/ServerContext/useServerContext';
|
||||||
|
import { useEventListeners } from './useEventListeners';
|
||||||
|
import { ScanResult, ServerInfo } from '../types/server.types';
|
||||||
|
import { Plugin } from '../types/plugin.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook that extends ServerContext with enhanced scan functionality
|
||||||
|
* with automatic event handling
|
||||||
|
*/
|
||||||
|
export const useServerActions = (onPluginsLoaded?: (plugins: Plugin[]) => void) => {
|
||||||
|
const {
|
||||||
|
serverPath,
|
||||||
|
serverInfo,
|
||||||
|
isScanning,
|
||||||
|
scanComplete,
|
||||||
|
error,
|
||||||
|
scanProgress,
|
||||||
|
scanForPlugins: contextScanForPlugins,
|
||||||
|
selectDirectory: contextSelectDirectory,
|
||||||
|
setServerPath,
|
||||||
|
clearError,
|
||||||
|
getPluginsPath
|
||||||
|
} = useServerContext();
|
||||||
|
|
||||||
|
// Local state for enhanced functionality
|
||||||
|
const [lastScanResult, setLastScanResult] = useState<ScanResult | null>(null);
|
||||||
|
|
||||||
|
// Handle scan events
|
||||||
|
useEventListeners({
|
||||||
|
onScanStarted: () => {
|
||||||
|
console.log('Scan started handled in useServerActions');
|
||||||
|
// This is already handled by the context, no additional action needed
|
||||||
|
},
|
||||||
|
onScanProgress: (progress) => {
|
||||||
|
console.log('Scan progress handled in useServerActions:', progress);
|
||||||
|
// This is already handled by the context, no additional action needed
|
||||||
|
},
|
||||||
|
onScanCompleted: (result) => {
|
||||||
|
console.log('Scan completed handled in useServerActions:', result);
|
||||||
|
setLastScanResult(result);
|
||||||
|
|
||||||
|
// If a callback was provided, call it with the plugins
|
||||||
|
if (onPluginsLoaded) {
|
||||||
|
onPluginsLoaded(result.plugins);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onScanError: (errorMessage) => {
|
||||||
|
console.log('Scan error handled in useServerActions:', errorMessage);
|
||||||
|
// This is already handled by the context, no additional action needed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enhanced select directory with post-selection actions
|
||||||
|
const selectDirectory = useCallback(async () => {
|
||||||
|
await contextSelectDirectory();
|
||||||
|
// The rest is handled by the context and event listeners
|
||||||
|
}, [contextSelectDirectory]);
|
||||||
|
|
||||||
|
// Enhanced scan for plugins with additional options
|
||||||
|
const scanForPlugins = useCallback(async () => {
|
||||||
|
// Reset the last scan result if we're starting a new scan
|
||||||
|
setLastScanResult(null);
|
||||||
|
|
||||||
|
// Call the context's scan function
|
||||||
|
await contextScanForPlugins();
|
||||||
|
// The rest is handled by the context and event listeners
|
||||||
|
}, [contextScanForPlugins]);
|
||||||
|
|
||||||
|
// Get server name based on server info
|
||||||
|
const getServerName = useCallback((): string => {
|
||||||
|
if (!serverInfo) return 'Unknown Server';
|
||||||
|
|
||||||
|
let name = serverInfo.server_type;
|
||||||
|
if (serverInfo.minecraft_version) {
|
||||||
|
name += ` (${serverInfo.minecraft_version})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}, [serverInfo]);
|
||||||
|
|
||||||
|
// Check if server is compatible with a specific server type
|
||||||
|
const isCompatibleWith = useCallback((serverTypes: string[]): boolean => {
|
||||||
|
if (!serverInfo) return false;
|
||||||
|
return serverTypes.includes(serverInfo.server_type);
|
||||||
|
}, [serverInfo]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Original context values
|
||||||
|
serverPath,
|
||||||
|
serverInfo,
|
||||||
|
isScanning,
|
||||||
|
scanComplete,
|
||||||
|
error,
|
||||||
|
scanProgress,
|
||||||
|
setServerPath,
|
||||||
|
clearError,
|
||||||
|
getPluginsPath,
|
||||||
|
|
||||||
|
// Enhanced functionality
|
||||||
|
lastScanResult,
|
||||||
|
selectDirectory,
|
||||||
|
scanForPlugins,
|
||||||
|
getServerName,
|
||||||
|
isCompatibleWith
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useServerActions;
|
113
src/hooks/useUpdateActions.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { usePluginContext } from '../context/PluginContext/usePluginContext';
|
||||||
|
import { useUIContext } from '../context/UIContext/useUIContext';
|
||||||
|
import { useEventListeners } from './useEventListeners';
|
||||||
|
import { useServerContext } from '../context/ServerContext/useServerContext';
|
||||||
|
import { Plugin } from '../types/plugin.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing plugin updates
|
||||||
|
*/
|
||||||
|
export const useUpdateActions = () => {
|
||||||
|
const {
|
||||||
|
plugins,
|
||||||
|
checkForUpdates: checkForPluginUpdates,
|
||||||
|
updatePlugin,
|
||||||
|
bulkUpdateProgress,
|
||||||
|
isCheckingUpdates
|
||||||
|
} = usePluginContext();
|
||||||
|
|
||||||
|
const {
|
||||||
|
showWarningMessage
|
||||||
|
} = useUIContext();
|
||||||
|
|
||||||
|
const { serverPath } = useServerContext();
|
||||||
|
|
||||||
|
// Listen for update-related events
|
||||||
|
useEventListeners({
|
||||||
|
onUpdateCheckComplete: (result) => {
|
||||||
|
console.log('Update check complete handled in useUpdateActions:', result);
|
||||||
|
|
||||||
|
if (result.outdated_count > 0) {
|
||||||
|
showWarningMessage(
|
||||||
|
`Found ${result.outdated_count} plugin(s) with updates available.`,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showWarningMessage('All plugins are up to date!', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update all plugins with available updates
|
||||||
|
const updateAllPlugins = useCallback(async () => {
|
||||||
|
const pluginsToUpdate = plugins.filter(plugin => plugin.has_update);
|
||||||
|
|
||||||
|
if (pluginsToUpdate.length === 0) {
|
||||||
|
showWarningMessage('No plugins with updates available.', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actual update process is handled by the PluginContext
|
||||||
|
// This is just a convenience function to find and trigger updates
|
||||||
|
for (const plugin of pluginsToUpdate) {
|
||||||
|
try {
|
||||||
|
await updatePlugin(plugin);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error updating plugin ${plugin.name}:`, error);
|
||||||
|
// Continue with other plugins if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [plugins, updatePlugin, showWarningMessage]);
|
||||||
|
|
||||||
|
// Check for plugin updates with improved debugging
|
||||||
|
const checkForAllUpdates = useCallback(async () => {
|
||||||
|
console.log('Starting plugin update check in useUpdateActions');
|
||||||
|
|
||||||
|
// Get the latest server path directly from context
|
||||||
|
const currentServerPath = serverPath;
|
||||||
|
|
||||||
|
console.log('Current state:', {
|
||||||
|
serverPath: currentServerPath,
|
||||||
|
pluginsCount: plugins.length,
|
||||||
|
isCheckingUpdates
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentServerPath) {
|
||||||
|
console.error('No server path available. Please select a server first.');
|
||||||
|
showWarningMessage('No server path available. Please select a server first.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugins.length) {
|
||||||
|
console.error('No plugins available to check for updates.');
|
||||||
|
showWarningMessage('No plugins available to check for updates.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCheckingUpdates) {
|
||||||
|
console.log('Update check already in progress, ignoring request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Calling checkForPluginUpdates with serverPath:', currentServerPath);
|
||||||
|
await checkForPluginUpdates();
|
||||||
|
console.log('Plugin update check completed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for plugin updates:', error);
|
||||||
|
showWarningMessage(`Failed to check for updates: ${String(error)}`, 'error');
|
||||||
|
}
|
||||||
|
}, [checkForPluginUpdates, plugins.length, serverPath, isCheckingUpdates, showWarningMessage]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Plugin update state from context
|
||||||
|
bulkUpdateProgress,
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
updateAllPlugins,
|
||||||
|
checkForAllUpdates
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUpdateActions;
|
27
src/types/events.types.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Plugin } from './plugin.types';
|
||||||
|
|
||||||
|
export interface BulkUpdateProgressPayload {
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
current_plugin_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SingleUpdateResultPayload {
|
||||||
|
original_file_path: string;
|
||||||
|
plugin: Plugin | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadProgress {
|
||||||
|
pluginName: string;
|
||||||
|
version: string;
|
||||||
|
percentage: number;
|
||||||
|
status: 'downloading' | 'extracting' | 'installing' | 'completed' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PremiumPluginInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
url: string;
|
||||||
|
}
|
41
src/types/plugin.types.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
export interface Plugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
latest_version?: string;
|
||||||
|
description?: string;
|
||||||
|
authors: string[];
|
||||||
|
has_update: boolean;
|
||||||
|
api_version?: string;
|
||||||
|
main_class?: string;
|
||||||
|
depend?: string[] | null;
|
||||||
|
soft_depend?: string[] | null;
|
||||||
|
load_before?: string[] | null;
|
||||||
|
commands?: any;
|
||||||
|
permissions?: any;
|
||||||
|
file_path: string;
|
||||||
|
file_hash: string;
|
||||||
|
website?: string;
|
||||||
|
changelog?: string;
|
||||||
|
repository_source?: string;
|
||||||
|
repository_id?: string;
|
||||||
|
repository_url?: string;
|
||||||
|
platform_compatibility?: string[];
|
||||||
|
found_by_flexible_search?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDetailsProps {
|
||||||
|
plugin: Plugin;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PotentialPluginMatch {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
repository: string;
|
||||||
|
repository_id: string;
|
||||||
|
page_url: string;
|
||||||
|
description?: string;
|
||||||
|
minecraft_versions: string[];
|
||||||
|
download_count?: number;
|
||||||
|
}
|
30
src/types/server.types.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export type ServerType =
|
||||||
|
| 'Paper'
|
||||||
|
| 'Spigot'
|
||||||
|
| 'Bukkit'
|
||||||
|
| 'Vanilla'
|
||||||
|
| 'Forge'
|
||||||
|
| 'Fabric'
|
||||||
|
| 'Velocity'
|
||||||
|
| 'BungeeCord'
|
||||||
|
| 'Waterfall'
|
||||||
|
| 'Unknown';
|
||||||
|
|
||||||
|
export interface ServerInfo {
|
||||||
|
server_type: ServerType;
|
||||||
|
minecraft_version?: string;
|
||||||
|
plugins_directory: string;
|
||||||
|
plugins_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
server_info: ServerInfo;
|
||||||
|
plugins: import('./plugin.types').Plugin[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanProgress {
|
||||||
|
current: number;
|
||||||
|
processed?: number;
|
||||||
|
total: number;
|
||||||
|
current_file: string;
|
||||||
|
}
|
114
src/types/tauri.d.ts
vendored
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
declare module '@tauri-apps/api/shell' {
|
||||||
|
/**
|
||||||
|
* Opens a path or URL with the default application.
|
||||||
|
* @param path The path or URL to open.
|
||||||
|
* @returns A promise that resolves when the command finishes.
|
||||||
|
*/
|
||||||
|
export function open(path: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tauri-apps/api/core' {
|
||||||
|
/**
|
||||||
|
* Invokes a Tauri command.
|
||||||
|
* @param cmd The command name.
|
||||||
|
* @param args Command arguments.
|
||||||
|
* @returns A promise resolving to the command result.
|
||||||
|
*/
|
||||||
|
export function invoke<T = any>(cmd: string, args?: Record<string, unknown>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tauri-apps/api/core/updater' {
|
||||||
|
export interface UpdateResult {
|
||||||
|
available: boolean;
|
||||||
|
manifest?: {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
shouldUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an update is available.
|
||||||
|
* @returns A promise resolving to the update check result.
|
||||||
|
*/
|
||||||
|
export function checkUpdate(): Promise<UpdateResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the available update.
|
||||||
|
* @returns A promise that resolves when the update is installed.
|
||||||
|
*/
|
||||||
|
export function installUpdate(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tauri-apps/api/core/path' {
|
||||||
|
/**
|
||||||
|
* Returns the path to the application data directory.
|
||||||
|
* @returns A promise resolving to the app data directory path.
|
||||||
|
*/
|
||||||
|
export function appDataDir(): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the path to the application configuration directory.
|
||||||
|
* @returns A promise resolving to the app config directory path.
|
||||||
|
*/
|
||||||
|
export function appConfigDir(): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the path to the application local data directory.
|
||||||
|
* @returns A promise resolving to the app local data directory path.
|
||||||
|
*/
|
||||||
|
export function appLocalDataDir(): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the path to the application cache directory.
|
||||||
|
* @returns A promise resolving to the app cache directory path.
|
||||||
|
*/
|
||||||
|
export function appCacheDir(): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the path to the application log directory.
|
||||||
|
* @returns A promise resolving to the app log directory path.
|
||||||
|
*/
|
||||||
|
export function appLogDir(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tauri-apps/api/core/event' {
|
||||||
|
export interface Event<T> {
|
||||||
|
payload: T;
|
||||||
|
windowLabel?: string;
|
||||||
|
event: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventCallback<T> = (event: Event<T>) => void;
|
||||||
|
export type UnlistenFn = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to an event from the backend.
|
||||||
|
* @param event The event name.
|
||||||
|
* @param handler The event handler.
|
||||||
|
* @returns A promise resolving to a function to unlisten to the event.
|
||||||
|
*/
|
||||||
|
export function listen<T>(event: string, handler: EventCallback<T>): Promise<UnlistenFn>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to an event from the backend once.
|
||||||
|
* @param event The event name.
|
||||||
|
* @param handler The event handler.
|
||||||
|
* @returns A promise resolving to a function to unlisten to the event.
|
||||||
|
*/
|
||||||
|
export function once<T>(event: string, handler: EventCallback<T>): Promise<UnlistenFn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the old path declarations for backward compatibility
|
||||||
|
declare module '@tauri-apps/api/event' {
|
||||||
|
export * from '@tauri-apps/api/core/event';
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tauri-apps/api/updater' {
|
||||||
|
export * from '@tauri-apps/api/core/updater';
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tauri-apps/api/path' {
|
||||||
|
export * from '@tauri-apps/api/core/path';
|
||||||
|
}
|
61
src/utils/appUpdates.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
export interface UpdateInfo {
|
||||||
|
available: boolean;
|
||||||
|
version?: string;
|
||||||
|
body?: string;
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment detection - check if running in a Tauri environment
|
||||||
|
const isTauriApp = window && typeof window !== 'undefined' && 'window.__TAURI__' in window;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there's an app update available
|
||||||
|
* @returns Promise with update information
|
||||||
|
*/
|
||||||
|
export async function checkForAppUpdate(): Promise<UpdateInfo> {
|
||||||
|
if (!isTauriApp) {
|
||||||
|
console.log('Update check skipped - not running in Tauri environment');
|
||||||
|
return { available: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamically import the module only when in Tauri context
|
||||||
|
// We need to use a variable for the import path to prevent Vite from trying to resolve it at build time
|
||||||
|
const updaterModule = '@tauri-apps/api/updater';
|
||||||
|
const { checkUpdate } = await import(/* @vite-ignore */ updaterModule);
|
||||||
|
const response = await checkUpdate();
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: response.available,
|
||||||
|
version: response.manifest?.version,
|
||||||
|
body: response.manifest?.body,
|
||||||
|
date: response.manifest?.date
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for update:', error);
|
||||||
|
return { available: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the available app update
|
||||||
|
* Note: This will restart the app if successful
|
||||||
|
* @returns Promise that resolves when update is complete or fails
|
||||||
|
*/
|
||||||
|
export async function installAppUpdate(): Promise<void> {
|
||||||
|
if (!isTauriApp) {
|
||||||
|
console.log('Update installation skipped - not running in Tauri environment');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamically import the module only when in Tauri context
|
||||||
|
const updaterModule = '@tauri-apps/api/updater';
|
||||||
|
const { installUpdate } = await import(/* @vite-ignore */ updaterModule);
|
||||||
|
await installUpdate();
|
||||||
|
// The app will be restarted by Tauri if successful
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error installing update:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
61
src/utils/formatters.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { ServerType } from '../types/server.types';
|
||||||
|
import { Plugin } from '../types/plugin.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a list of authors into a comma-separated string
|
||||||
|
* @param authors Array of author names
|
||||||
|
* @returns Formatted authors string
|
||||||
|
*/
|
||||||
|
export function formatAuthors(authors: string[]): string {
|
||||||
|
if (!authors || authors.length === 0) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return authors.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats platform compatibility information into a user-friendly label
|
||||||
|
* @param plugin The plugin object
|
||||||
|
* @param currentServerType The current server type
|
||||||
|
* @returns A formatted compatibility string
|
||||||
|
*/
|
||||||
|
export function getPlatformCompatibilityLabel(plugin: Plugin, currentServerType?: ServerType): string {
|
||||||
|
const platformCompatibility = plugin.platform_compatibility || [];
|
||||||
|
if (platformCompatibility.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current server type is in the compatibility list
|
||||||
|
const isCompatible = currentServerType &&
|
||||||
|
platformCompatibility.includes(currentServerType);
|
||||||
|
|
||||||
|
if (isCompatible) {
|
||||||
|
return `Compatible with your ${currentServerType} server`;
|
||||||
|
} else if (currentServerType) {
|
||||||
|
return `Compatible with ${platformCompatibility.join(', ')} (You're using ${currentServerType})`;
|
||||||
|
} else {
|
||||||
|
return `Compatible with ${platformCompatibility.join(', ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a status message for plugin updates
|
||||||
|
* @param pluginName Name of the plugin
|
||||||
|
* @param version Version being updated to
|
||||||
|
* @param platformCompatibility Array of compatible platforms
|
||||||
|
* @returns Formatted update message
|
||||||
|
*/
|
||||||
|
export function createUpdateMessage(pluginName: string, version?: string, platformCompatibility?: string[]): string {
|
||||||
|
if (!version) {
|
||||||
|
return `Updating ${pluginName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateMessage = `Updating ${pluginName} to version ${version}`;
|
||||||
|
|
||||||
|
if (platformCompatibility && platformCompatibility.length > 0) {
|
||||||
|
updateMessage += ` (${platformCompatibility.join(', ')} compatible)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateMessage;
|
||||||
|
}
|
54
src/utils/serverUtils.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { ServerType } from '../types/server.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an emoji icon representing the server type
|
||||||
|
* @param serverType The type of Minecraft server
|
||||||
|
* @returns A string emoji representing the server type
|
||||||
|
*/
|
||||||
|
export function getServerTypeIcon(serverType: ServerType): string {
|
||||||
|
switch (serverType) {
|
||||||
|
case 'Paper':
|
||||||
|
return '📄';
|
||||||
|
case 'Spigot':
|
||||||
|
return '🔌';
|
||||||
|
case 'Bukkit':
|
||||||
|
return '🪣';
|
||||||
|
case 'Vanilla':
|
||||||
|
return '🧊';
|
||||||
|
case 'Forge':
|
||||||
|
return '🔨';
|
||||||
|
case 'Fabric':
|
||||||
|
return '🧵';
|
||||||
|
case 'Velocity':
|
||||||
|
return '⚡';
|
||||||
|
case 'BungeeCord':
|
||||||
|
return '🔗';
|
||||||
|
case 'Waterfall':
|
||||||
|
return '🌊';
|
||||||
|
default:
|
||||||
|
return '❓';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a formatted display name for the server type
|
||||||
|
* @param serverType The type of Minecraft server
|
||||||
|
* @returns A formatted string for display
|
||||||
|
*/
|
||||||
|
export function getServerTypeName(serverType: ServerType): string {
|
||||||
|
return serverType === 'Unknown' ? 'Unknown Server' : serverType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a plugin is compatible with the current server type
|
||||||
|
* @param compatibility Array of compatible server types
|
||||||
|
* @param currentServerType The current server type
|
||||||
|
* @returns True if compatible, false otherwise
|
||||||
|
*/
|
||||||
|
export function isCompatibleWithServer(compatibility: string[] | undefined, currentServerType: ServerType | undefined): boolean {
|
||||||
|
if (!compatibility || compatibility.length === 0 || !currentServerType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compatibility.includes(currentServerType);
|
||||||
|
}
|
63
src/utils/validators.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Plugin } from '../types/plugin.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a plugin can be updated
|
||||||
|
* @param plugin The plugin to check
|
||||||
|
* @returns True if the plugin can be updated, false otherwise
|
||||||
|
*/
|
||||||
|
export function canUpdatePlugin(plugin: Plugin): boolean {
|
||||||
|
return !!(
|
||||||
|
plugin.has_update &&
|
||||||
|
plugin.latest_version &&
|
||||||
|
(plugin.repository_source || plugin.repository_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a plugin is from a premium source that requires manual download
|
||||||
|
* @param errorMessage The error message to check
|
||||||
|
* @returns True if the plugin is premium, false otherwise
|
||||||
|
*/
|
||||||
|
export function isPremiumPluginError(errorMessage: string): boolean {
|
||||||
|
return errorMessage.startsWith("PREMIUM_RESOURCE:");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract premium plugin information from an error message
|
||||||
|
* @param errorMessage The error message to parse
|
||||||
|
* @returns An object with plugin id, version, and resource URL or null if parsing fails
|
||||||
|
*/
|
||||||
|
export function extractPremiumPluginInfo(errorMessage: string): { pluginId?: string, version?: string, resourceUrl: string } | null {
|
||||||
|
if (!isPremiumPluginError(errorMessage)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resourceUrl = "";
|
||||||
|
let pluginId = "";
|
||||||
|
let version = "";
|
||||||
|
|
||||||
|
// Handle the format with all parts (PREMIUM_RESOURCE:id:version:url)
|
||||||
|
const parts = errorMessage.split(":");
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
pluginId = parts[1];
|
||||||
|
version = parts[2];
|
||||||
|
resourceUrl = parts.slice(3).join(":"); // Rejoin in case URL contains colons
|
||||||
|
return { pluginId, version, resourceUrl };
|
||||||
|
}
|
||||||
|
// Handle the simpler format (PREMIUM_RESOURCE:url)
|
||||||
|
else if (parts.length >= 2) {
|
||||||
|
resourceUrl = parts.slice(1).join(":"); // Get everything after PREMIUM_RESOURCE:
|
||||||
|
return { resourceUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a path string is empty or invalid
|
||||||
|
* @param path The path to validate
|
||||||
|
* @returns True if the path is valid, false otherwise
|
||||||
|
*/
|
||||||
|
export function isValidPath(path: string | undefined): boolean {
|
||||||
|
return !!path && path.trim().length > 0;
|
||||||
|
}
|