Add React components and complete project structure for PlugSnatcher

This commit is contained in:
Rbanh 2025-04-01 00:09:51 -04:00
parent 340ce3d834
commit 61becf8d22
92 changed files with 8603 additions and 1524 deletions

View File

@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
You must update your current roadmap file before/after EVERY Change.

View 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.

View File

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

View 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
View File

@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: false
---

View File

@ -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
View File

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

View File

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 49 KiB

0
src-tauri/schema.json Normal file
View File

View File

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

File diff suppressed because it is too large Load Diff

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

View 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);

View 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);
}
}

View 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);

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

View 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"
>
&times;
</button>
)}
</div>
)}
<div className="modal-content" id="modal-content">
{children}
</div>
</div>
</div>
);
};
export default Modal;

View File

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

View File

@ -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"
>
&times;
</button>
)}
</div>
);
};
export default NotificationDisplay;

View 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);
}

View 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;

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

View 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;

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

View 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;

View 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);
}
}

View 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;

View 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);
}
}

View 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;

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

View 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;

View 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);
}
}

View 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);

View 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 */
}

View 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;

View 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);
}
}

View 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;

View 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);
}

View 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;

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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;

View 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);
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

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