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] Complete GitHub integration
|
||||
- [ ] 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)
|
||||
- [x] Implement changelog extraction
|
||||
- [x] Create plugin backup functionality
|
||||
@ -72,14 +74,14 @@
|
||||
- [x] Display up-to-date version information for all plugins
|
||||
- [x] Handle premium plugins with user guidance for manual downloads
|
||||
- [ ] 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
|
||||
- [ ] Server platform compatibility matching (High Priority)
|
||||
- [ ] Detect server platform and version accurately (Paper, Spigot, Forge, NeoForge, Fabric, etc.)
|
||||
- [ ] Filter plugin updates to match the server platform
|
||||
- [ ] Prevent incompatible version updates
|
||||
- [ ] Add platform indicators in the UI for available updates
|
||||
- [ ] Allow manual selection of target platform when multiple are available
|
||||
- [x] Server platform compatibility matching
|
||||
- [x] Detect server platform and version accurately (Paper, Spigot, Forge, NeoForge, Fabric, etc.)
|
||||
- [x] Filter plugin updates to match the server platform
|
||||
- [x] Prevent incompatible version updates
|
||||
- [x] Add compatibility indicators in the code for available updates
|
||||
- [x] Add platform indicators in the UI for available updates
|
||||
|
||||
## UI Development (In Progress)
|
||||
- [x] Design and implement main dashboard
|
||||
@ -95,9 +97,11 @@
|
||||
- [ ] Create settings panel
|
||||
- [x] Implement dark mode
|
||||
- [ ] 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 detailed progress logging in UI for debugging
|
||||
- [x] Implement application update checking functionality
|
||||
- [x] Display application update notifications
|
||||
|
||||
## Security Features (Upcoming)
|
||||
- [ ] 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">
|
||||
<head>
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
<body>
|
||||
|
26
package-lock.json
generated
@ -8,14 +8,15 @@
|
||||
"name": "plugsnatcher",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.4.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@ -1303,10 +1304,19 @@
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.2.6.tgz",
|
||||
"integrity": "sha512-bSdkuP71ZQRepPOn8BOEdBKYJQvl6+jb160QtJX/i2H9BF6ZySY/kYljh76N2Ne5fJMQRge7rlKoStYQY5Jq1w==",
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.2.0.tgz",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0"
|
||||
|
3
src-tauri/.gitignore
vendored
@ -1,7 +1,4 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
|
1270
src-tauri/Cargo.lock
generated
@ -1,3 +1,3 @@
|
||||
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;
|
||||
--surface-color: #292a2d;
|
||||
--text-color: #e8eaed;
|
||||
--text-secondary-color: #9aa0a6;
|
||||
--text-secondary-color: #a8adb4;
|
||||
--border-color: #3c4043;
|
||||
--error-color: #f44336;
|
||||
--warning-color: #ff9800;
|
||||
--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-size: 16px;
|
||||
line-height: 24px;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
color: var(--text-color);
|
||||
background-color: var(--background-color);
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@ -38,7 +49,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
@ -51,45 +62,58 @@ body {
|
||||
|
||||
.app-header {
|
||||
background-color: var(--surface-color);
|
||||
padding: 1rem;
|
||||
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
color: var(--text-secondary-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
padding: calc(var(--spacing-unit) * 3);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-section {
|
||||
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||
}
|
||||
|
||||
.server-selector {
|
||||
background-color: var(--surface-color);
|
||||
padding: 1.5rem;
|
||||
padding: calc(var(--spacing-unit) * 3);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.server-selector h2 {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
padding: calc(var(--spacing-unit) * 1.5);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-color);
|
||||
@ -98,39 +122,145 @@ body {
|
||||
}
|
||||
|
||||
.input-group button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
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);
|
||||
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;
|
||||
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%;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.scan-button:hover {
|
||||
background-color: #43a047;
|
||||
background-color: color-mix(in srgb, var(--secondary-color) 90%, black);
|
||||
}
|
||||
|
||||
.scan-button:disabled {
|
||||
background-color: #666;
|
||||
background-color: var(--text-secondary-color);
|
||||
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 {
|
||||
@ -157,34 +287,33 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plugin-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-version {
|
||||
color: var(--text-secondary-color);
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.update-available {
|
||||
background-color: #4caf50;
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
padding: calc(var(--spacing-unit) * 0.5) var(--spacing-unit);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
margin-left: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.plugins-count {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
@ -202,45 +331,42 @@ body {
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.plugin-actions > div {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.update-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--warning-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.update-button:hover {
|
||||
background-color: #f57c00;
|
||||
background-color: color-mix(in srgb, var(--warning-color) 90%, black);
|
||||
}
|
||||
|
||||
.info-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.info-button:hover {
|
||||
background-color: #1967d2;
|
||||
background-color: color-mix(in srgb, var(--primary-color) 90%, black);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
background-color: var(--surface-color);
|
||||
padding: 1rem;
|
||||
padding: calc(var(--spacing-unit) * 1.5) calc(var(--spacing-unit) * 3);
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: auto;
|
||||
margin-top: calc(var(--spacing-unit) * 4);
|
||||
color: var(--text-secondary-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
@ -437,9 +563,8 @@ button {
|
||||
|
||||
.server-info {
|
||||
background-color: var(--surface-color);
|
||||
padding: 1.5rem;
|
||||
padding: calc(var(--spacing-unit) * 2.5);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@ -558,7 +683,7 @@ button {
|
||||
}
|
||||
|
||||
.update-available-badge {
|
||||
background-color: var(--secondary-color);
|
||||
background-color: var(--warning-color);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
@ -566,22 +691,58 @@ button {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.up-to-date-badge {
|
||||
color: var(--success-color, #4caf50);
|
||||
font-weight: bold;
|
||||
margin-left: 0.5rem;
|
||||
.up-to-date-text {
|
||||
color: var(--success-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.up-to-date-text {
|
||||
color: var(--success-color, #4caf50);
|
||||
font-size: 0.85em;
|
||||
margin-left: 0.4rem;
|
||||
.up-to-date-badge a,
|
||||
.update-available-badge a {
|
||||
text-decoration: underline;
|
||||
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 {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary-color);
|
||||
margin-top: 1rem;
|
||||
background-color: var(--surface-color);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bulk-update-progress progress {
|
||||
@ -716,3 +877,448 @@ button {
|
||||
.close-modal-button:hover {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
1070
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;
|
||||
}
|