diff --git a/.cursor/rules/roadmap.mdc b/.cursor/rules/roadmap.mdc
new file mode 100644
index 0000000..f488709
--- /dev/null
+++ b/.cursor/rules/roadmap.mdc
@@ -0,0 +1,6 @@
+---
+description:
+globs:
+alwaysApply: true
+---
+You must update your current roadmap file before/after EVERY Change.
\ No newline at end of file
diff --git a/PlugSnatcher Frontend Refactoring Plan.md b/PlugSnatcher Frontend Refactoring Plan.md
new file mode 100644
index 0000000..20473c0
--- /dev/null
+++ b/PlugSnatcher Frontend Refactoring Plan.md
@@ -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.
\ No newline at end of file
diff --git a/ROADMAP.md b/ROADMAP.md
index ba39ba2..367ffc1 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -57,7 +57,9 @@
- [x] Improve matching algorithm to reduce false positives
- [x] Complete GitHub integration
- [ ] Add GitHub API authentication for higher rate limits (environment variable support exists)
- - [ ] Fix command parameter naming issues for update checks
+ - [x] Fix command parameter naming issues for update checks
+ - [x] Fix plugin update checking functionality after UI refactor
+ - [x] Fix serverPath not being passed correctly to PluginContext
- [ ] Optimize duplicate plugin search results (e.g., ViaVersion plugin)
- [x] Implement changelog extraction
- [x] Create plugin backup functionality
@@ -72,14 +74,14 @@
- [x] Display up-to-date version information for all plugins
- [x] Handle premium plugins with user guidance for manual downloads
- [ ] Present multiple potential matches for ambiguous plugins
- - [ ] Make version numbers clickable links to repository sources
+ - [x] Make version numbers clickable links to repository sources
- [ ] Allow user selection of correct plugin match when multiple are found
-- [ ] Server platform compatibility matching (High Priority)
- - [ ] Detect server platform and version accurately (Paper, Spigot, Forge, NeoForge, Fabric, etc.)
- - [ ] Filter plugin updates to match the server platform
- - [ ] Prevent incompatible version updates
- - [ ] Add platform indicators in the UI for available updates
- - [ ] Allow manual selection of target platform when multiple are available
+- [x] Server platform compatibility matching
+ - [x] Detect server platform and version accurately (Paper, Spigot, Forge, NeoForge, Fabric, etc.)
+ - [x] Filter plugin updates to match the server platform
+ - [x] Prevent incompatible version updates
+ - [x] Add compatibility indicators in the code for available updates
+ - [x] Add platform indicators in the UI for available updates
## UI Development (In Progress)
- [x] Design and implement main dashboard
@@ -95,9 +97,11 @@
- [ ] Create settings panel
- [x] Implement dark mode
- [ ] Implement plugin matching disambiguation UI
-- [ ] Add clickable version links to repository pages
+- [x] Add clickable version links to repository pages
- [ ] Add error recovery UI for failed updates
- [ ] Add detailed progress logging in UI for debugging
+- [x] Implement application update checking functionality
+- [x] Display application update notifications
## Security Features (Upcoming)
- [ ] Implement sandboxing for network requests
diff --git a/UI_UX_Improvement_Task_List.md b/UI_UX_Improvement_Task_List.md
new file mode 100644
index 0000000..d8b9e5a
--- /dev/null
+++ b/UI_UX_Improvement_Task_List.md
@@ -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.
\ No newline at end of file
diff --git a/code-styling.mdc b/code-styling.mdc
new file mode 100644
index 0000000..5634a59
--- /dev/null
+++ b/code-styling.mdc
@@ -0,0 +1,6 @@
+---
+description:
+globs:
+alwaysApply: false
+---
+
\ No newline at end of file
diff --git a/index.html b/index.html
index ff93803..57720d9 100644
--- a/index.html
+++ b/index.html
@@ -2,9 +2,10 @@
-
+
- Tauri + React + Typescript
+
+ PlugSnatcher - Minecraft Server Plugin Manager
diff --git a/package-lock.json b/package-lock.json
index 3d00051..14981c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore
index b21bd68..502406b 100644
--- a/src-tauri/.gitignore
+++ b/src-tauri/.gitignore
@@ -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
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 88024ac..9baaa9f 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -30,14 +30,13 @@ dependencies = [
[[package]]
name = "ahash"
-version = "0.8.11"
+version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
+checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
- "cfg-if",
+ "getrandom 0.2.15",
"once_cell",
"version_check",
- "zerocopy 0.7.35",
]
[[package]]
@@ -64,18 +63,29 @@ dependencies = [
"alloc-no-stdlib",
]
-[[package]]
-name = "allocator-api2"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
-
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+[[package]]
+name = "android_log-sys"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
+
+[[package]]
+name = "android_logger"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f39be698127218cca460cb624878c9aa4e2b47dba3b277963d2bf00bad263b"
+dependencies = [
+ "android_log-sys",
+ "env_filter",
+ "log",
+]
+
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -91,6 +101,40 @@ version = "1.0.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
+[[package]]
+name = "app"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "base64 0.21.7",
+ "cached",
+ "futures",
+ "log",
+ "regex",
+ "reqwest 0.11.27",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-dialog",
+ "tauri-plugin-fs",
+ "tauri-plugin-log",
+ "tauri-plugin-shell",
+ "tokio",
+ "urlencoding",
+ "walkdir",
+ "yaml-rust",
+ "zip",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
[[package]]
name = "ashpd"
version = "0.11.0"
@@ -115,106 +159,12 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
- "event-listener 5.4.0",
+ "event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
-[[package]]
-name = "async-channel"
-version = "2.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
-dependencies = [
- "concurrent-queue",
- "event-listener-strategy",
- "futures-core",
- "pin-project-lite",
-]
-
-[[package]]
-name = "async-executor"
-version = "1.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec"
-dependencies = [
- "async-task",
- "concurrent-queue",
- "fastrand",
- "futures-lite",
- "slab",
-]
-
-[[package]]
-name = "async-fs"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a"
-dependencies = [
- "async-lock",
- "blocking",
- "futures-lite",
-]
-
-[[package]]
-name = "async-io"
-version = "2.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059"
-dependencies = [
- "async-lock",
- "cfg-if",
- "concurrent-queue",
- "futures-io",
- "futures-lite",
- "parking",
- "polling",
- "rustix 0.38.44",
- "slab",
- "tracing",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "async-lock"
-version = "3.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
-dependencies = [
- "event-listener 5.4.0",
- "event-listener-strategy",
- "pin-project-lite",
-]
-
-[[package]]
-name = "async-mutex"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "479db852db25d9dbf6204e6cb6253698f175c15726470f78af0d918e99d6156e"
-dependencies = [
- "event-listener 2.5.3",
-]
-
-[[package]]
-name = "async-process"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb"
-dependencies = [
- "async-channel",
- "async-io",
- "async-lock",
- "async-signal",
- "async-task",
- "blocking",
- "cfg-if",
- "event-listener 5.4.0",
- "futures-lite",
- "rustix 0.38.44",
- "tracing",
-]
-
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -226,30 +176,6 @@ dependencies = [
"syn 2.0.100",
]
-[[package]]
-name = "async-signal"
-version = "0.2.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3"
-dependencies = [
- "async-io",
- "async-lock",
- "atomic-waker",
- "cfg-if",
- "futures-core",
- "futures-io",
- "rustix 0.38.44",
- "signal-hook-registry",
- "slab",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "async-task"
-version = "4.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
-
[[package]]
name = "async-trait"
version = "0.1.88"
@@ -284,12 +210,6 @@ dependencies = [
"system-deps",
]
-[[package]]
-name = "atomic-waker"
-version = "1.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
-
[[package]]
name = "autocfg"
version = "1.4.0"
@@ -344,6 +264,18 @@ dependencies = [
"serde",
]
+[[package]]
+name = "bitvec"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
+dependencies = [
+ "funty",
+ "radium",
+ "tap",
+ "wyz",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -372,16 +304,26 @@ dependencies = [
]
[[package]]
-name = "blocking"
-version = "1.6.1"
+name = "borsh"
+version = "1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea"
+checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce"
dependencies = [
- "async-channel",
- "async-task",
- "futures-io",
- "futures-lite",
- "piper",
+ "borsh-derive",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate 3.3.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.100",
]
[[package]]
@@ -411,6 +353,39 @@ version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
+[[package]]
+name = "byte-unit"
+version = "5.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cd29c3c585209b0cbc7309bfe3ed7efd8c84c21b7af29c8bfae908f8777174"
+dependencies = [
+ "rust_decimal",
+ "serde",
+ "utf8-width",
+]
+
+[[package]]
+name = "bytecheck"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
+dependencies = [
+ "bytecheck_derive",
+ "ptr_meta",
+ "simdutf8",
+]
+
+[[package]]
+name = "bytecheck_derive"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "bytemuck"
version = "1.22.0"
@@ -454,16 +429,15 @@ dependencies = [
[[package]]
name = "cached"
-version = "0.52.0"
+version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a8466736fe5dbcaf8b8ee24f9bbefe43c884dc3e9ff7178da70f55bffca1133c"
+checksum = "b195e4fbc4b6862bbd065b991a34750399c119797efff72492f28a5864de8700"
dependencies = [
- "ahash",
"async-trait",
"cached_proc_macro",
"cached_proc_macro_types",
"futures",
- "hashbrown 0.14.5",
+ "hashbrown 0.13.2",
"instant",
"once_cell",
"thiserror 1.0.69",
@@ -472,14 +446,15 @@ dependencies = [
[[package]]
name = "cached_proc_macro"
-version = "0.22.0"
+version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "575f32e012222055211b70f5b0601f951f84523410a0e65c81f2744a6042450d"
+checksum = "b48814962d2fd604c50d2b9433c2a41a0ab567779ee2c02f7fba6eca1221f082"
dependencies = [
- "darling",
+ "cached_proc_macro_types",
+ "darling 0.14.4",
"proc-macro2",
"quote",
- "syn 2.0.100",
+ "syn 1.0.109",
]
[[package]]
@@ -669,6 +644,16 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation"
version = "0.10.0"
@@ -692,9 +677,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.9.0",
- "core-foundation",
+ "core-foundation 0.10.0",
"core-graphics-types",
- "foreign-types",
+ "foreign-types 0.5.0",
"libc",
]
@@ -705,7 +690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.9.0",
- "core-foundation",
+ "core-foundation 0.10.0",
"libc",
]
@@ -789,14 +774,38 @@ dependencies = [
"syn 2.0.100",
]
+[[package]]
+name = "darling"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850"
+dependencies = [
+ "darling_core 0.14.4",
+ "darling_macro 0.14.4",
+]
+
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
- "darling_core",
- "darling_macro",
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim 0.10.0",
+ "syn 1.0.109",
]
[[package]]
@@ -809,17 +818,28 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
- "strsim",
+ "strsim 0.11.1",
"syn 2.0.100",
]
+[[package]]
+name = "darling_macro"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e"
+dependencies = [
+ "darling_core 0.14.4",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
- "darling_core",
+ "darling_core 0.20.11",
"quote",
"syn 2.0.100",
]
@@ -978,7 +998,7 @@ dependencies = [
"rustc_version",
"toml",
"vswhom",
- "winreg",
+ "winreg 0.52.0",
]
[[package]]
@@ -987,6 +1007,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "endi"
version = "1.1.0"
@@ -1014,6 +1043,16 @@ dependencies = [
"syn 2.0.100",
]
+[[package]]
+name = "env_filter"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
+dependencies = [
+ "log",
+ "regex",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1040,12 +1079,6 @@ dependencies = [
"windows-sys 0.59.0",
]
-[[package]]
-name = "event-listener"
-version = "2.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
-
[[package]]
name = "event-listener"
version = "5.4.0"
@@ -1063,7 +1096,7 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
- "event-listener 5.4.0",
+ "event-listener",
"pin-project-lite",
]
@@ -1082,6 +1115,15 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "fern"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
+dependencies = [
+ "log",
+]
+
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1108,6 +1150,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared 0.1.1",
+]
+
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1115,7 +1166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
- "foreign-types-shared",
+ "foreign-types-shared 0.3.1",
]
[[package]]
@@ -1129,6 +1180,12 @@ dependencies = [
"syn 2.0.100",
]
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1144,6 +1201,12 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "funty"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+
[[package]]
name = "futf"
version = "0.1.5"
@@ -1392,10 +1455,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
- "js-sys",
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
- "wasm-bindgen",
]
[[package]]
@@ -1405,11 +1466,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
dependencies = [
"cfg-if",
- "js-sys",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
- "wasm-bindgen",
]
[[package]]
@@ -1566,21 +1625,39 @@ dependencies = [
"syn 2.0.100",
]
+[[package]]
+name = "h2"
+version = "0.3.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http 0.2.12",
+ "indexmap 2.8.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+dependencies = [
+ "ahash",
+]
[[package]]
name = "hashbrown"
-version = "0.14.5"
+version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
-dependencies = [
- "ahash",
- "allocator-api2",
-]
+checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
[[package]]
name = "hashbrown"
@@ -1600,12 +1677,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-[[package]]
-name = "hermit-abi"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
-
[[package]]
name = "hex"
version = "0.4.3"
@@ -1635,6 +1706,17 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa 1.0.15",
+]
+
[[package]]
name = "http"
version = "1.3.1"
@@ -1646,6 +1728,17 @@ dependencies = [
"itoa 1.0.15",
]
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
[[package]]
name = "http-body"
version = "1.0.1"
@@ -1653,7 +1746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.3.1",
]
[[package]]
@@ -1664,8 +1757,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
- "http",
- "http-body",
+ "http 1.3.1",
+ "http-body 1.0.1",
"pin-project-lite",
]
@@ -1675,6 +1768,36 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "0.14.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa 1.0.15",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
[[package]]
name = "hyper"
version = "1.6.0"
@@ -1684,8 +1807,8 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
- "http",
- "http-body",
+ "http 1.3.1",
+ "http-body 1.0.1",
"httparse",
"itoa 1.0.15",
"pin-project-lite",
@@ -1695,35 +1818,31 @@ dependencies = [
]
[[package]]
-name = "hyper-rustls"
-version = "0.27.5"
+name = "hyper-tls"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
+checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
- "futures-util",
- "http",
- "hyper",
- "hyper-util",
- "rustls",
- "rustls-pki-types",
+ "bytes",
+ "hyper 0.14.32",
+ "native-tls",
"tokio",
- "tokio-rustls",
- "tower-service",
- "webpki-roots",
+ "tokio-native-tls",
]
[[package]]
name = "hyper-util"
-version = "0.1.10"
+version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4"
+checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
- "http",
- "http-body",
- "hyper",
+ "http 1.3.1",
+ "http-body 1.0.1",
+ "hyper 1.6.0",
+ "libc",
"pin-project-lite",
"socket2",
"tokio",
@@ -1733,9 +1852,9 @@ dependencies = [
[[package]]
name = "iana-time-zone"
-version = "0.1.62"
+version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2fd658b06e56721792c5df4475705b6cda790e9298d19d2f8af083457bcd127"
+checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [
"android_system_properties",
"core-foundation-sys",
@@ -1743,7 +1862,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.52.0",
+ "windows-core 0.61.0",
]
[[package]]
@@ -2168,12 +2287,6 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
-[[package]]
-name = "linux-raw-sys"
-version = "0.4.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
-
[[package]]
name = "linux-raw-sys"
version = "0.9.3"
@@ -2201,6 +2314,9 @@ name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+dependencies = [
+ "value-bag",
+]
[[package]]
name = "mac"
@@ -2291,6 +2407,23 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "native-tls"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2382,6 +2515,15 @@ dependencies = [
"syn 2.0.100",
]
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -2621,6 +2763,50 @@ dependencies = [
"pathdiff",
]
+[[package]]
+name = "openssl"
+version = "0.10.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd"
+dependencies = [
+ "bitflags 2.9.0",
+ "cfg-if",
+ "foreign-types 0.3.2",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.100",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2637,6 +2823,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "os_pipe"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "pango"
version = "0.18.3"
@@ -2872,17 +3068,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
-[[package]]
-name = "piper"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
-dependencies = [
- "atomic-waker",
- "fastrand",
- "futures-io",
-]
-
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -2902,33 +3087,6 @@ dependencies = [
"time",
]
-[[package]]
-name = "plugsnatcher"
-version = "0.1.0"
-dependencies = [
- "async-mutex",
- "async-trait",
- "base64 0.21.7",
- "cached",
- "futures",
- "regex",
- "reqwest",
- "semver",
- "serde",
- "serde_json",
- "sha2",
- "tauri",
- "tauri-build",
- "tauri-plugin-dialog",
- "tauri-plugin-opener",
- "tokio",
- "url",
- "urlencoding",
- "walkdir",
- "yaml-rust",
- "zip",
-]
-
[[package]]
name = "png"
version = "0.17.16"
@@ -2942,21 +3100,6 @@ dependencies = [
"miniz_oxide",
]
-[[package]]
-name = "polling"
-version = "3.7.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f"
-dependencies = [
- "cfg-if",
- "concurrent-queue",
- "hermit-abi",
- "pin-project-lite",
- "rustix 0.38.44",
- "tracing",
- "windows-sys 0.59.0",
-]
-
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -2969,7 +3112,7 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
- "zerocopy 0.8.24",
+ "zerocopy",
]
[[package]]
@@ -3045,6 +3188,26 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "ptr_meta"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
+dependencies = [
+ "ptr_meta_derive",
+]
+
+[[package]]
+name = "ptr_meta_derive"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "quick-xml"
version = "0.32.0"
@@ -3054,60 +3217,6 @@ dependencies = [
"memchr",
]
-[[package]]
-name = "quinn"
-version = "0.11.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012"
-dependencies = [
- "bytes",
- "cfg_aliases",
- "pin-project-lite",
- "quinn-proto",
- "quinn-udp",
- "rustc-hash",
- "rustls",
- "socket2",
- "thiserror 2.0.12",
- "tokio",
- "tracing",
- "web-time",
-]
-
-[[package]]
-name = "quinn-proto"
-version = "0.11.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc"
-dependencies = [
- "bytes",
- "getrandom 0.3.2",
- "rand 0.9.0",
- "ring",
- "rustc-hash",
- "rustls",
- "rustls-pki-types",
- "slab",
- "thiserror 2.0.12",
- "tinyvec",
- "tracing",
- "web-time",
-]
-
-[[package]]
-name = "quinn-udp"
-version = "0.5.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5"
-dependencies = [
- "cfg_aliases",
- "libc",
- "once_cell",
- "socket2",
- "tracing",
- "windows-sys 0.59.0",
-]
-
[[package]]
name = "quote"
version = "1.0.40"
@@ -3123,6 +3232,12 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
+[[package]]
+name = "radium"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
+
[[package]]
name = "rand"
version = "0.7.3"
@@ -3156,7 +3271,7 @@ checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
- "zerocopy 0.8.24",
+ "zerocopy",
]
[[package]]
@@ -3289,6 +3404,55 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
+[[package]]
+name = "rend"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
+dependencies = [
+ "bytecheck",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.11.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
+dependencies = [
+ "base64 0.21.7",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
+ "hyper-tls",
+ "ipnet",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pemfile",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper 0.1.2",
+ "system-configuration",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "winreg 0.50.0",
+]
+
[[package]]
name = "reqwest"
version = "0.12.15"
@@ -3299,11 +3463,10 @@ dependencies = [
"bytes",
"futures-core",
"futures-util",
- "http",
- "http-body",
+ "http 1.3.1",
+ "http-body 1.0.1",
"http-body-util",
- "hyper",
- "hyper-rustls",
+ "hyper 1.6.0",
"hyper-util",
"ipnet",
"js-sys",
@@ -3312,16 +3475,11 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
- "quinn",
- "rustls",
- "rustls-pemfile",
- "rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
- "sync_wrapper",
+ "sync_wrapper 1.0.2",
"tokio",
- "tokio-rustls",
"tokio-util",
"tower",
"tower-service",
@@ -3330,7 +3488,6 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
- "webpki-roots",
"windows-registry",
]
@@ -3360,17 +3517,48 @@ dependencies = [
]
[[package]]
-name = "ring"
-version = "0.17.14"
+name = "rkyv"
+version = "0.7.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b"
dependencies = [
- "cc",
- "cfg-if",
- "getrandom 0.2.15",
- "libc",
- "untrusted",
- "windows-sys 0.52.0",
+ "bitvec",
+ "bytecheck",
+ "bytes",
+ "hashbrown 0.12.3",
+ "ptr_meta",
+ "rend",
+ "rkyv_derive",
+ "seahash",
+ "tinyvec",
+ "uuid",
+]
+
+[[package]]
+name = "rkyv_derive"
+version = "0.7.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "rust_decimal"
+version = "1.37.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faa7de2ba56ac291bd90c6b9bece784a52ae1411f9506544b3eae36dd2356d50"
+dependencies = [
+ "arrayvec",
+ "borsh",
+ "bytes",
+ "num-traits",
+ "rand 0.8.5",
+ "rkyv",
+ "serde",
+ "serde_json",
]
[[package]]
@@ -3379,12 +3567,6 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
-[[package]]
-name = "rustc-hash"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
-
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3394,19 +3576,6 @@ dependencies = [
"semver",
]
-[[package]]
-name = "rustix"
-version = "0.38.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
-dependencies = [
- "bitflags 2.9.0",
- "errno",
- "libc",
- "linux-raw-sys 0.4.15",
- "windows-sys 0.59.0",
-]
-
[[package]]
name = "rustix"
version = "1.0.3"
@@ -3416,51 +3585,17 @@ dependencies = [
"bitflags 2.9.0",
"errno",
"libc",
- "linux-raw-sys 0.9.3",
+ "linux-raw-sys",
"windows-sys 0.59.0",
]
-[[package]]
-name = "rustls"
-version = "0.23.25"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c"
-dependencies = [
- "once_cell",
- "ring",
- "rustls-pki-types",
- "rustls-webpki",
- "subtle",
- "zeroize",
-]
-
[[package]]
name = "rustls-pemfile"
-version = "2.2.0"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
+checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
dependencies = [
- "rustls-pki-types",
-]
-
-[[package]]
-name = "rustls-pki-types"
-version = "1.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c"
-dependencies = [
- "web-time",
-]
-
-[[package]]
-name = "rustls-webpki"
-version = "0.103.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03"
-dependencies = [
- "ring",
- "rustls-pki-types",
- "untrusted",
+ "base64 0.21.7",
]
[[package]]
@@ -3484,6 +3619,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "schannel"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3517,6 +3661,35 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "seahash"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.9.0",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "selectors"
version = "0.22.0"
@@ -3656,7 +3829,7 @@ version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
dependencies = [
- "darling",
+ "darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.100",
@@ -3716,6 +3889,16 @@ dependencies = [
"digest",
]
+[[package]]
+name = "shared_child"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09fa9338aed9a1df411814a5b2252f7cd206c55ae9bf2fa763f8de84603aa60c"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -3737,6 +3920,12 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -3766,9 +3955,9 @@ checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"
[[package]]
name = "socket2"
-version = "0.5.8"
+version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8"
+checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
dependencies = [
"libc",
"windows-sys 0.52.0",
@@ -3783,7 +3972,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
- "foreign-types",
+ "foreign-types 0.5.0",
"js-sys",
"log",
"objc2 0.5.2",
@@ -3859,6 +4048,12 @@ dependencies = [
"quote",
]
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3904,6 +4099,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
+
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -3924,6 +4125,27 @@ dependencies = [
"syn 2.0.100",
]
+[[package]]
+name = "system-configuration"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3944,7 +4166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63c8b1020610b9138dd7b1e06cf259ae91aa05c30f3bd0d6b42a03997b92dec1"
dependencies = [
"bitflags 2.9.0",
- "core-foundation",
+ "core-foundation 0.10.0",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -3987,6 +4209,12 @@ dependencies = [
"syn 2.0.100",
]
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4009,7 +4237,7 @@ dependencies = [
"glob",
"gtk",
"heck 0.5.0",
- "http",
+ "http 1.3.1",
"jni",
"libc",
"log",
@@ -4021,7 +4249,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
- "reqwest",
+ "reqwest 0.12.15",
"serde",
"serde_json",
"serde_repr",
@@ -4125,9 +4353,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
-version = "2.2.0"
+version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b59fd750551b1066744ab956a1cd6b1ea3e1b3763b0b9153ac27a044d596426"
+checksum = "4307310e1d2c09ab110235834722e7c2b85099b683e1eb7342ab351b0be5ada3"
dependencies = [
"log",
"raw-window-handle",
@@ -4137,15 +4365,15 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
- "thiserror 2.0.12",
+ "thiserror 1.0.69",
"url",
]
[[package]]
name = "tauri-plugin-fs"
-version = "2.2.0"
+version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1a1edf18000f02903a7c2e5997fb89aca455ecbc0acc15c6535afbb883be223"
+checksum = "96ba7d46e86db8c830d143ef90ab5a453328365b0cc834c24edea4267b16aba0"
dependencies = [
"anyhow",
"dunce",
@@ -4157,33 +4385,52 @@ dependencies = [
"serde_repr",
"tauri",
"tauri-plugin",
- "tauri-utils",
- "thiserror 2.0.12",
- "toml",
+ "thiserror 1.0.69",
"url",
"uuid",
]
[[package]]
-name = "tauri-plugin-opener"
-version = "2.2.6"
+name = "tauri-plugin-log"
+version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2fdc6cb608e04b7d2b6d1f21e9444ad49245f6d03465ba53323d692d1ceb1a30"
+checksum = "2341d5b9bc5318c8e34f35a569140c78337241aa9c14091550b424c49f0314e0"
dependencies = [
- "dunce",
- "glob",
- "objc2-app-kit",
+ "android_logger",
+ "byte-unit",
+ "fern",
+ "log",
+ "objc2 0.6.0",
"objc2-foundation 0.3.0",
- "open",
- "schemars",
"serde",
"serde_json",
+ "serde_repr",
+ "swift-rs",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
- "url",
- "windows",
- "zbus",
+ "time",
+]
+
+[[package]]
+name = "tauri-plugin-shell"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ad7880c5586b6b2104be451e3d7fc0f3800c84bda69e9ba81c828f87cb34267"
+dependencies = [
+ "encoding_rs",
+ "log",
+ "open",
+ "os_pipe",
+ "regex",
+ "schemars",
+ "serde",
+ "serde_json",
+ "shared_child",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 1.0.69",
+ "tokio",
]
[[package]]
@@ -4195,7 +4442,7 @@ dependencies = [
"cookie",
"dpi",
"gtk",
- "http",
+ "http 1.3.1",
"jni",
"raw-window-handle",
"serde",
@@ -4213,7 +4460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "087188020fd6facb8578fe9b38e81fa0fe5fb85744c73da51a299f94a530a1e3"
dependencies = [
"gtk",
- "http",
+ "http 1.3.1",
"jni",
"log",
"objc2 0.6.0",
@@ -4246,7 +4493,7 @@ dependencies = [
"dunce",
"glob",
"html5ever",
- "http",
+ "http 1.3.1",
"infer",
"json-patch",
"kuchikiki",
@@ -4290,7 +4537,7 @@ dependencies = [
"fastrand",
"getrandom 0.3.2",
"once_cell",
- "rustix 1.0.3",
+ "rustix",
"windows-sys 0.59.0",
]
@@ -4359,7 +4606,9 @@ checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
"deranged",
"itoa 1.0.15",
+ "libc",
"num-conv",
+ "num_threads",
"powerfmt",
"serde",
"time-core",
@@ -4438,12 +4687,12 @@ dependencies = [
]
[[package]]
-name = "tokio-rustls"
-version = "0.26.2"
+name = "tokio-native-tls"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
- "rustls",
+ "native-tls",
"tokio",
]
@@ -4525,7 +4774,7 @@ dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
- "sync_wrapper",
+ "sync_wrapper 1.0.2",
"tokio",
"tower-layer",
"tower-service",
@@ -4678,12 +4927,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
-[[package]]
-name = "untrusted"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
-
[[package]]
name = "url"
version = "2.5.4"
@@ -4726,6 +4969,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
+[[package]]
+name = "utf8-width"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4742,6 +4991,18 @@ dependencies = [
"serde",
]
+[[package]]
+name = "value-bag"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
[[package]]
name = "version-compare"
version = "0.2.0"
@@ -4908,16 +5169,6 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "web-time"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
[[package]]
name = "webkit2gtk"
version = "2.0.1"
@@ -4962,15 +5213,6 @@ dependencies = [
"system-deps",
]
-[[package]]
-name = "webpki-roots"
-version = "0.26.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9"
-dependencies = [
- "rustls-pki-types",
-]
-
[[package]]
name = "webview2-com"
version = "0.36.0"
@@ -4981,7 +5223,7 @@ dependencies = [
"webview2-com-sys",
"windows",
"windows-core 0.60.1",
- "windows-implement",
+ "windows-implement 0.59.0",
"windows-interface",
]
@@ -5075,26 +5317,30 @@ dependencies = [
"windows-core 0.60.1",
]
-[[package]]
-name = "windows-core"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
-dependencies = [
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows-core"
version = "0.60.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247"
dependencies = [
- "windows-implement",
+ "windows-implement 0.59.0",
"windows-interface",
"windows-link",
"windows-result",
- "windows-strings",
+ "windows-strings 0.3.1",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
+dependencies = [
+ "windows-implement 0.60.0",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings 0.4.0",
]
[[package]]
@@ -5118,6 +5364,17 @@ dependencies = [
"syn 2.0.100",
]
+[[package]]
+name = "windows-implement"
+version = "0.60.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.100",
+]
+
[[package]]
name = "windows-interface"
version = "0.59.1"
@@ -5152,7 +5409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [
"windows-result",
- "windows-strings",
+ "windows-strings 0.3.1",
"windows-targets 0.53.0",
]
@@ -5174,6 +5431,15 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-strings"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -5479,6 +5745,16 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winreg"
+version = "0.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.48.0",
+]
+
[[package]]
name = "winreg"
version = "0.52.0"
@@ -5525,7 +5801,7 @@ dependencies = [
"gdkx11",
"gtk",
"html5ever",
- "http",
+ "http 1.3.1",
"javascriptcore-rs",
"jni",
"kuchikiki",
@@ -5554,6 +5830,15 @@ dependencies = [
"x11-dl",
]
+[[package]]
+name = "wyz"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
+dependencies = [
+ "tap",
+]
+
[[package]]
name = "x11"
version = "2.21.0"
@@ -5625,17 +5910,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59c333f648ea1b647bc95dc1d34807c8e25ed7a6feff3394034dc4776054b236"
dependencies = [
"async-broadcast",
- "async-executor",
- "async-fs",
- "async-io",
- "async-lock",
- "async-process",
"async-recursion",
- "async-task",
"async-trait",
- "blocking",
"enumflags2",
- "event-listener 5.4.0",
+ "event-listener",
"futures-core",
"futures-lite",
"hex",
@@ -5682,33 +5960,13 @@ dependencies = [
"zvariant",
]
-[[package]]
-name = "zerocopy"
-version = "0.7.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
-dependencies = [
- "zerocopy-derive 0.7.35",
-]
-
[[package]]
name = "zerocopy"
version = "0.8.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
dependencies = [
- "zerocopy-derive 0.8.24",
-]
-
-[[package]]
-name = "zerocopy-derive"
-version = "0.7.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.100",
+ "zerocopy-derive",
]
[[package]]
@@ -5743,12 +6001,6 @@ dependencies = [
"synstructure",
]
-[[package]]
-name = "zeroize"
-version = "1.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
-
[[package]]
name = "zerovec"
version = "0.10.4"
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index d860e1e..795b9b7 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -1,3 +1,3 @@
fn main() {
- tauri_build::build()
+ tauri_build::build()
}
diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png
index 6be5e50..77e7d23 100644
Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ
diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png
index e81bece..0f7976f 100644
Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ
diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png
index a437dd5..98fda06 100644
Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ
diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png
index 0ca4f27..f35d84f 100644
Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ
diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png
index b81f820..1823bb2 100644
Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ
diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png
index 624c7bf..dc2b22c 100644
Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ
diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png
index c021d2b..0ed3984 100644
Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ
diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png
index 6219700..60bf0ea 100644
Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ
diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png
index f9bc048..c8ca0ad 100644
Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ
diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png
index d5fbfb2..8756459 100644
Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ
diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png
index 63440d7..2c8023c 100644
Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ
diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png
index f3f705a..2c5e603 100644
Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ
diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png
index 4556388..17d142c 100644
Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ
diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns
index 12a5bce..a2993ad 100644
Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ
diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico
index b3636e4..06c23c8 100644
Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ
diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png
index e1cd261..d1756ce 100644
Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ
diff --git a/src-tauri/schema.json b/src-tauri/schema.json
new file mode 100644
index 0000000..e69de29
diff --git a/src/App.css b/src/App.css
index ff427fa..6fab12c 100644
--- a/src/App.css
+++ b/src/App.css
@@ -11,18 +11,29 @@
--background-color: #202124;
--surface-color: #292a2d;
--text-color: #e8eaed;
- --text-secondary-color: #9aa0a6;
+ --text-secondary-color: #a8adb4;
--border-color: #3c4043;
--error-color: #f44336;
--warning-color: #ff9800;
--success-color: #4caf50;
+
+ /* Hover/Active Colors */
+ --primary-hover: color-mix(in srgb, var(--primary-color) 90%, black);
+ --secondary-hover: color-mix(in srgb, var(--secondary-color) 90%, black);
+ --danger-hover: color-mix(in srgb, var(--error-color) 90%, black);
+ --success-hover: color-mix(in srgb, var(--success-color) 90%, black);
+ --warning-hover: color-mix(in srgb, var(--warning-color) 90%, black);
+ --hover-bg: rgba(255, 255, 255, 0.05); /* For outline/text hover */
+ --text-hover-bg: rgba(26, 115, 232, 0.1); /* Primary text hover */
+
+ --spacing-unit: 8px;
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
- line-height: 24px;
+ line-height: 1.5;
font-weight: 400;
- color: #0f0f0f;
- background-color: #f6f6f6;
+ color: var(--text-color);
+ background-color: var(--background-color);
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -38,7 +49,7 @@
}
body {
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-color);
}
@@ -51,45 +62,58 @@ body {
.app-header {
background-color: var(--surface-color);
- padding: 1rem;
+ padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
text-align: center;
border-bottom: 1px solid var(--border-color);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.app-header h1 {
- font-size: 2rem;
- margin-bottom: 0.5rem;
+ font-size: 1.8rem;
+ margin-bottom: var(--spacing-unit);
+ font-weight: 700;
+}
+
+.app-subtitle {
+ color: var(--text-secondary-color);
+ font-size: 1rem;
}
.app-content {
flex: 1;
- padding: 1rem;
+ padding: calc(var(--spacing-unit) * 3);
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
+.server-section {
+ margin-bottom: calc(var(--spacing-unit) * 3);
+}
+
.server-selector {
background-color: var(--surface-color);
- padding: 1.5rem;
+ padding: calc(var(--spacing-unit) * 3);
border-radius: 8px;
- margin-bottom: 1.5rem;
+ margin-bottom: calc(var(--spacing-unit) * 3);
border: 1px solid var(--border-color);
}
.server-selector h2 {
- margin-bottom: 1rem;
+ margin-bottom: calc(var(--spacing-unit) * 2);
font-size: 1.5rem;
+ font-weight: 600;
}
.input-group {
display: flex;
- margin-bottom: 1rem;
+ margin-bottom: var(--spacing-unit);
+ gap: 0;
}
.input-group input {
flex: 1;
- padding: 0.75rem;
+ padding: calc(var(--spacing-unit) * 1.5);
border: 1px solid var(--border-color);
background-color: rgba(255, 255, 255, 0.05);
color: var(--text-color);
@@ -98,39 +122,145 @@ body {
}
.input-group button {
- padding: 0.75rem 1.5rem;
- background-color: var(--primary-color);
- color: white;
- border: none;
- border-radius: 0 4px 4px 0;
- cursor: pointer;
- font-size: 1rem;
- transition: background-color 0.3s;
-}
-
-.input-group button:hover {
- background-color: #1967d2;
-}
-
-.scan-button {
- padding: 0.75rem 1.5rem;
- background-color: var(--secondary-color);
+ padding: calc(var(--spacing-unit) * 1.5);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
+ font-weight: 600;
+ transition: background-color 0.3s, opacity 0.3s;
+ text-align: center;
+ line-height: 1.2;
+ background-color: var(--primary-color);
+}
+
+.input-group button:hover {
+ opacity: 0.9;
+ background-color: color-mix(in srgb, var(--primary-color) 90%, black);
+}
+
+.scan-button,
+.update-button,
+.info-button,
+.check-button,
+.continue-button,
+.cancel-button,
+.close-modal-button,
+.plugin-page-button {
+ padding: calc(var(--spacing-unit) * 1.5);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: 600;
+ transition: background-color 0.3s, opacity 0.3s;
+ text-align: center;
+ line-height: 1.2;
+}
+
+.scan-button:hover,
+.update-button:hover,
+.info-button:hover,
+.check-button:hover,
+.continue-button:hover,
+.cancel-button:hover,
+.close-modal-button:hover,
+.plugin-page-button:hover {
+ opacity: 0.9;
+}
+
+.scan-button {
+ background-color: var(--secondary-color);
width: 100%;
- transition: background-color 0.3s;
}
.scan-button:hover {
- background-color: #43a047;
+ background-color: color-mix(in srgb, var(--secondary-color) 90%, black);
}
.scan-button:disabled {
- background-color: #666;
+ background-color: var(--text-secondary-color);
cursor: not-allowed;
+ opacity: 0.7;
+}
+
+.plugins-section {
+ background-color: var(--surface-color);
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid var(--border-color);
+ margin-bottom: calc(var(--spacing-unit) * 3);
+}
+
+.plugins-header-container {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
+ border-bottom: 1px solid var(--border-color);
+ background-color: rgba(255, 255, 255, 0.03);
+}
+
+.plugins-header-container h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin-right: 1rem;
+}
+
+.plugins-header-container > *:not(h2) {
+ margin-top: 0.5rem;
+}
+
+.plugin-grid-header {
+ display: grid;
+ grid-template-columns: 3fr 1fr 1.5fr 1.5fr;
+ padding: calc(var(--spacing-unit) * 1.5) calc(var(--spacing-unit) * 3);
+ font-weight: 600;
+ font-size: 0.9rem;
+ color: var(--text-secondary-color);
+ border-bottom: 1px solid var(--border-color);
+ background-color: rgba(255, 255, 255, 0.02);
+}
+
+.plugin-grid-header > div:nth-child(4) {
+ text-align: right;
+ justify-self: end;
+}
+
+.plugin-grid-header > div {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.plugin-list-container {
+ padding: 0;
+ border: none;
+ border-radius: 0;
+ background-color: transparent;
+}
+
+.plugin-list-container .search-container {
+ padding: calc(var(--spacing-unit) * 1.5) calc(var(--spacing-unit) * 2.5);
+ border-bottom: 1px solid var(--border-color);
+ background-color: rgba(255, 255, 255, 0.01);
+}
+
+.plugin-list-controls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: calc(var(--spacing-unit)) calc(var(--spacing-unit) * 2.5);
+ border-bottom: 1px solid var(--border-color);
+ font-size: 0.9rem;
+}
+
+.plugin-list {
+ padding: 0;
}
.plugins-list {
@@ -157,34 +287,33 @@ body {
align-items: center;
}
+.plugin-info {
+ flex: 1;
+ overflow: hidden;
+}
+
.plugin-version {
color: var(--text-secondary-color);
font-size: 0.9rem;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
}
.update-available {
- background-color: #4caf50;
+ background-color: var(--success-color);
color: white;
- padding: 4px 8px;
+ padding: calc(var(--spacing-unit) * 0.5) var(--spacing-unit);
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
+ margin-left: var(--spacing-unit);
}
.plugins-count {
margin-top: 0.5rem;
}
-.plugins-header {
- display: grid;
- grid-template-columns: 2fr 1fr 1fr 1fr;
- padding: 0.75rem 1rem;
- background-color: rgba(255, 255, 255, 0.05);
- border-radius: 4px 4px 0 0;
- font-weight: bold;
- border-bottom: 1px solid var(--border-color);
-}
-
.plugin-item {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
@@ -202,45 +331,42 @@ body {
}
.plugin-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.plugin-actions > div {
display: flex;
gap: 0.5rem;
}
.update-button {
- padding: 0.5rem 1rem;
background-color: var(--warning-color);
- color: white;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- transition: background-color 0.3s;
}
.update-button:hover {
- background-color: #f57c00;
+ background-color: color-mix(in srgb, var(--warning-color) 90%, black);
}
.info-button {
- padding: 0.5rem 1rem;
background-color: var(--primary-color);
- color: white;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- transition: background-color 0.3s;
}
.info-button:hover {
- background-color: #1967d2;
+ background-color: color-mix(in srgb, var(--primary-color) 90%, black);
}
.app-footer {
background-color: var(--surface-color);
- padding: 1rem;
+ padding: calc(var(--spacing-unit) * 1.5) calc(var(--spacing-unit) * 3);
text-align: center;
border-top: 1px solid var(--border-color);
- margin-top: auto;
+ margin-top: calc(var(--spacing-unit) * 4);
color: var(--text-secondary-color);
+ font-size: 0.9rem;
}
.container {
@@ -437,9 +563,8 @@ button {
.server-info {
background-color: var(--surface-color);
- padding: 1.5rem;
+ padding: calc(var(--spacing-unit) * 2.5);
border-radius: 8px;
- margin-bottom: 1.5rem;
border: 1px solid var(--border-color);
}
@@ -558,7 +683,7 @@ button {
}
.update-available-badge {
- background-color: var(--secondary-color);
+ background-color: var(--warning-color);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
@@ -566,22 +691,58 @@ button {
margin-left: 0.5rem;
}
-.up-to-date-badge {
- color: var(--success-color, #4caf50);
- font-weight: bold;
- margin-left: 0.5rem;
+.up-to-date-text {
+ color: var(--success-color);
+ display: inline-flex;
+ align-items: center;
+ font-weight: 500;
}
-.up-to-date-text {
- color: var(--success-color, #4caf50);
- font-size: 0.85em;
- margin-left: 0.4rem;
+.up-to-date-badge a,
+.update-available-badge a {
+ text-decoration: underline;
+ color: inherit;
+}
+
+.version-link {
+ color: var(--link-color, #3498db);
+ text-decoration: underline;
+ transition: color 0.2s;
+ font-weight: 500;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+
+.version-link.up-to-date-text {
+ color: var(--success-color);
+}
+
+.version-link:hover {
+ color: var(--highlight-color, #2980b9);
+ text-decoration: underline;
+}
+
+.version-link:after {
+ content: '↗';
+ font-size: 0.8em;
+ margin-left: 0.3em;
+ opacity: 0.7;
+}
+
+/* Improve platform badges layout */
+.platform-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ margin: 0.4rem 0;
}
.bulk-update-progress {
- margin-top: 0.5rem;
- font-size: 0.9rem;
- color: var(--text-secondary-color);
+ margin-top: 1rem;
+ background-color: var(--surface-color);
+ padding: 1rem;
+ border-radius: 4px;
}
.bulk-update-progress progress {
@@ -716,3 +877,448 @@ button {
.close-modal-button:hover {
background-color: var(--surface-hover-color, #616161);
}
+
+/* Platform compatibility styles */
+.plugin-platform-compatibility {
+ margin-bottom: 1.5rem;
+}
+
+.platform-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.platform-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ padding: 0.3rem 0.7rem;
+ border-radius: 12px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ margin: 0.15rem;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ text-shadow: 0 1px 1px rgba(0,0,0,0.2);
+}
+
+.platform-compatibility-indicator {
+ display: inline-flex;
+ margin-left: 0.5rem;
+ vertical-align: middle;
+}
+
+.compatible-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--success-color);
+ color: white;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ font-size: 0.7rem;
+ font-weight: bold;
+}
+
+.caution-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--warning-color);
+ color: white;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ font-size: 0.7rem;
+ font-weight: bold;
+}
+
+/* Improve warning modal for update notifications */
+.modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.warning-modal {
+ background-color: var(--surface-color);
+ border-radius: 8px;
+ padding: 1.5rem;
+ max-width: 500px;
+ width: 90%;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+ border: 1px solid var(--warning-color);
+}
+
+.warning-modal h3 {
+ color: var(--warning-color);
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+}
+
+.warning-modal h3::before {
+ content: '⚠️';
+ margin-right: 0.5rem;
+}
+
+.warning-modal p {
+ margin-bottom: 1.5rem;
+ line-height: 1.5;
+}
+
+.warning-modal button {
+ padding: 0.5rem 1rem;
+ background-color: var(--primary-color);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ float: right;
+}
+
+.warning-modal button:hover {
+ background-color: #1967d2;
+}
+
+/* Download Progress Styles */
+.download-progress-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 2000;
+}
+
+.download-progress-modal {
+ background-color: var(--surface-color);
+ border-radius: 8px;
+ padding: 1.5rem;
+ width: 90%;
+ max-width: 450px;
+ text-align: center;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+}
+
+.download-progress-modal h3 {
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.2rem;
+}
+
+.download-progress-modal p {
+ margin-bottom: 1.2rem;
+ color: var(--text-color);
+}
+
+.progress-bar-container {
+ width: 100%;
+ height: 8px;
+ background-color: rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 0.5rem;
+}
+
+.progress-bar {
+ height: 100%;
+ background-color: var(--primary-color);
+ border-radius: 4px;
+ transition: width 0.3s ease;
+}
+
+.progress-percentage {
+ font-size: 0.9rem;
+ color: var(--text-secondary-color);
+ margin-bottom: 0.5rem;
+}
+
+/* Custom animation for when download is completed */
+@keyframes fadeOut {
+ 0% { opacity: 1; }
+ 90% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+
+.download-progress-modal.completed {
+ animation: fadeOut 2s forwards;
+}
+
+/* Platform badge styles with improved colors */
+.platform-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ padding: 0.3rem 0.7rem;
+ border-radius: 12px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ margin: 0.15rem;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ text-shadow: 0 1px 1px rgba(0,0,0,0.2);
+}
+
+/* Enhanced server-specific colors */
+.platform-badge[data-server-type="Paper"] {
+ background-color: #3498db;
+}
+
+.platform-badge[data-server-type="Spigot"] {
+ background-color: #e67e22;
+}
+
+.platform-badge[data-server-type="Bukkit"] {
+ background-color: #2ecc71;
+}
+
+.platform-badge[data-server-type="Forge"] {
+ background-color: #9b59b6;
+}
+
+.platform-badge[data-server-type="NeoForge"] {
+ background-color: #8e44ad;
+}
+
+.platform-badge[data-server-type="Fabric"] {
+ background-color: #1abc9c;
+}
+
+.platform-badge[data-server-type="Velocity"] {
+ background-color: #f1c40f;
+}
+
+.platform-badge[data-server-type="BungeeCord"] {
+ background-color: #e74c3c;
+}
+
+.platform-badge[data-server-type="Waterfall"] {
+ background-color: #3498db;
+}
+
+/* Compatibility Check Dialog */
+.compatibility-dialog {
+ background-color: var(--surface-color);
+ border-radius: 8px;
+ padding: 1.5rem;
+ width: 90%;
+ max-width: 500px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
+}
+
+.compatibility-dialog h3 {
+ margin-bottom: 1rem;
+ text-align: center;
+ font-size: 1.3rem;
+ color: var(--text-color);
+}
+
+.plugin-info-section {
+ background-color: rgba(255, 255, 255, 0.05);
+ padding: 0.8rem;
+ border-radius: 4px;
+ margin-bottom: 1.2rem;
+ text-align: center;
+}
+
+.compatibility-info {
+ margin-bottom: 1.5rem;
+}
+
+.compatibility-status {
+ display: inline-block;
+ padding: 0.3rem 0.6rem;
+ border-radius: 4px;
+ margin-right: 0.5rem;
+ font-weight: bold;
+}
+
+.compatibility-status.compatible {
+ background-color: var(--success-color);
+ color: white;
+}
+
+.compatibility-status.caution {
+ background-color: var(--warning-color);
+ color: white;
+}
+
+.compatibility-status.unknown {
+ background-color: var(--text-secondary-color);
+ color: white;
+}
+
+.compatibility-warning {
+ margin-top: 0.8rem;
+ padding: 0.8rem;
+ background-color: rgba(255, 152, 0, 0.1);
+ border-left: 3px solid var(--warning-color);
+ border-radius: 0 4px 4px 0;
+}
+
+.compatibility-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+ margin-top: 1.5rem;
+}
+
+.continue-button {
+ padding: 0.6rem 1.2rem;
+ background-color: var(--success-color);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.continue-button:hover {
+ background-color: #3d9140;
+}
+
+.continue-button.caution-button {
+ background-color: var(--warning-color);
+}
+
+.continue-button.caution-button:hover {
+ background-color: #e68a00;
+}
+
+.cancel-button {
+ padding: 0.6rem 1.2rem;
+ background-color: var(--surface-alt-color, #424242);
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.cancel-button:hover {
+ background-color: var(--surface-hover-color, #616161);
+}
+
+/* Flexible match warning in plugin details */
+.flexible-match-warning {
+ background-color: rgba(255, 152, 0, 0.1);
+ border: 1px solid var(--warning-color);
+ border-radius: 4px;
+ padding: 0.75rem;
+ margin-bottom: 1.5rem;
+ display: flex;
+ align-items: center;
+ font-size: 0.9rem;
+ line-height: 1.4;
+}
+
+.warning-icon {
+ margin-right: 0.5rem;
+ font-size: 1.1rem;
+}
+
+/* Flexible match indicator in plugin list */
+.flexible-match-indicator {
+ color: var(--warning-color);
+ font-weight: bold;
+}
+
+.check-button {
+ padding: 0.5rem 1rem;
+ background-color: transparent;
+ border: 1px solid var(--primary-color);
+ color: var(--primary-color);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.check-button:hover {
+ background-color: rgba(26, 115, 232, 0.1);
+}
+
+.check-button:disabled {
+ background-color: #666;
+ cursor: not-allowed;
+}
+
+/* Make platform badges more visible */
+.platform-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ margin: 0.4rem 0;
+}
+
+.platform-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ padding: 0.3rem 0.7rem;
+ border-radius: 12px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ margin: 0.15rem 0;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ text-shadow: 0 1px 1px rgba(0,0,0,0.2);
+}
+
+/* Make sure plugin actions has the right display */
+.plugin-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+@media (max-width: 900px) {
+ .app-header {
+ padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
+ }
+ .app-header h1 {
+ font-size: 1.6rem;
+ }
+ .app-subtitle {
+ font-size: 0.9rem;
+ }
+ .app-content {
+ padding: calc(var(--spacing-unit) * 2);
+ }
+ .server-section {
+ margin-bottom: calc(var(--spacing-unit) * 2);
+ }
+ .plugin-grid-header {
+ display: none;
+ }
+ .plugin-item {
+ grid-template-columns: 1fr;
+ gap: var(--spacing-unit);
+ padding: var(--spacing-unit) * 2;
+ }
+ .plugin-version, .plugin-compatibility, .plugin-actions {
+ justify-content: flex-start;
+ }
+ .plugin-details-content {
+ padding: calc(var(--spacing-unit) * 2);
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
index 5509a73..30f7f09 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,969 +1,209 @@
-import { useState, useEffect } from "react";
-import { invoke } from "@tauri-apps/api/core";
-import { open } from "@tauri-apps/plugin-dialog";
-import { listen, UnlistenFn } from "@tauri-apps/api/event";
-import { appDataDir } from '@tauri-apps/api/path'; // Import for data directory (if needed frontend side)
-import "./App.css";
+import React, { useEffect } from 'react';
+import './App.css';
-type ServerType =
- | 'Paper'
- | 'Spigot'
- | 'Bukkit'
- | 'Vanilla'
- | 'Forge'
- | 'Fabric'
- | 'Velocity'
- | 'BungeeCord'
- | 'Waterfall'
- | 'Unknown';
+// Import context providers
+import { ServerProvider } from './context/ServerContext/ServerContext';
+import { PluginProvider } from './context/PluginContext/PluginContext';
+import UIProvider from './context/UIContext/UIContext';
+import { useUIContext } from './context/UIContext/useUIContext';
-interface ServerInfo {
- server_type: ServerType;
- minecraft_version?: string;
- plugins_directory: string;
- plugins_count: number;
-}
+// Import layout components
+import Footer from './components/layout/Footer/Footer';
+import MainContent from './components/layout/MainContent/MainContent';
-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; // Add repository source (string for simplicity)
- repository_id?: string; // Add repository ID
- repository_url?: string; // URL to the plugin's repository page
-}
+// Import server components
+import ServerSelector from './components/server/ServerSelector/ServerSelector';
+import ServerInfo from './components/server/ServerInfo/ServerInfo';
+import ScanProgress from './components/server/ScanProgress/ScanProgress';
-interface ScanResult {
- server_info: ServerInfo;
- plugins: Plugin[];
-}
+// Import plugin components
+import PluginList from './components/plugins/PluginList/PluginList';
+import PluginDetails from './components/plugins/PluginDetails/PluginDetails';
+import NoPluginsMessage from './components/plugins/NoPluginsMessage/NoPluginsMessage';
-interface ScanProgress {
- processed: number;
- total: number;
- current_file: string;
-}
+// Import update components
+import UpdateControls from './components/updates/UpdateControls/UpdateControls';
+import BulkUpdateProgress from './components/updates/BulkUpdateProgress/BulkUpdateProgress';
+import CompatibilityCheckDialog from './components/updates/CompatibilityCheckDialog/CompatibilityCheckDialog';
+import PremiumPluginModal from './components/updates/PremiumPluginModal/PremiumPluginModal';
+import DownloadProgressIndicator from './components/updates/DownloadProgressIndicator/DownloadProgressIndicator';
+import PluginMatchSelector from './components/updates/PluginMatchSelector/PluginMatchSelector';
+import WarningModal from './components/updates/WarningModal/WarningModal';
-// --- New Interfaces for Update Events ---
-interface BulkUpdateProgressPayload {
- processed: number;
- total: number;
- current_plugin_name: string;
-}
+// Import common components
+import NotificationDisplay from './components/common/NotificationDisplay/NotificationDisplay';
-interface SingleUpdateResultPayload {
- original_file_path: string;
- plugin: Plugin | null; // Updated plugin state or null if check failed but wasn't a panic
- error: string | null; // Error message if any
-}
-
-// Interface for potential plugin matches for ambiguous plugins
-interface PotentialPluginMatch {
- name: string;
- version: string;
- repository: string;
- repository_id: string;
- page_url: string;
- description?: string;
- minecraft_versions: string[];
- download_count?: number;
-}
-
-// --- End New Interfaces ---
-
-interface PluginDetailsProps {
- plugin: Plugin;
- onClose: () => void;
-}
-
-// Get server type icon
-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 '❓';
- }
-}
-
-// Get a formatted server type name for display
-function getServerTypeName(serverType: ServerType): string {
- return serverType === 'Unknown' ? 'Unknown Server' : serverType;
-}
-
-function PluginDetails({ plugin, onClose }: PluginDetailsProps) {
- return (
-
-
-
×
-
{plugin.name}
-
Version: {plugin.version} {plugin.latest_version && (
- plugin.has_update
- ? (Update available: {plugin.latest_version})
- : (Up to Date: {plugin.latest_version})
- )}
-
-
- {plugin.description && (
-
{plugin.description}
- )}
-
- {plugin.website && (
-
- )}
-
- {plugin.authors && plugin.authors.length > 0 && (
-
-
Authors:
-
{plugin.authors.join(", ")}
-
- )}
-
- {plugin.depend && plugin.depend.length > 0 && (
-
-
Dependencies:
-
{plugin.depend.join(", ")}
-
- )}
-
- {plugin.soft_depend && plugin.soft_depend.length > 0 && (
-
-
Soft Dependencies:
-
{plugin.soft_depend.join(", ")}
-
- )}
-
- {plugin.changelog && plugin.has_update && (
-
-
Changelog:
-
{plugin.changelog}
-
- )}
-
-
-
File Path:
-
{plugin.file_path}
-
-
File Hash (SHA-256):
-
{plugin.file_hash}
-
-
- {plugin.has_update && plugin.latest_version && (
-
- window.dispatchEvent(new CustomEvent('update-plugin', { detail: plugin }))}
- >
- Update to version {plugin.latest_version}
-
-
- )}
-
-
- );
-}
-
-// Add this component after the PluginDetails component and before the App component
-function ServerInfoDisplay({ serverInfo }: { serverInfo: ServerInfo | null }) {
- if (!serverInfo) return null;
-
- return (
-
-
Server Information
-
- {getServerTypeIcon(serverInfo.server_type)}
- {getServerTypeName(serverInfo.server_type)}
-
-
- Minecraft Version
- {serverInfo.minecraft_version || "Unknown"}
-
-
- Plugins Directory
- {serverInfo.plugins_directory || "Unknown"}
-
-
- {serverInfo.plugins_count} plugins found
-
-
- );
-}
-
-// Add this new component after PluginDetails
-function WarningModal({ message, onClose }: { message: string, onClose: () => void }) {
- return (
-
-
-
⚠️ Warning
-
{message}
-
Close
-
-
- );
-}
-
-// Component for selecting the correct plugin from potential matches
-function PluginMatchSelector({
- plugin,
- potentialMatches,
- onSelect,
- onCancel
-}: {
- plugin: Plugin,
- potentialMatches: PotentialPluginMatch[],
- onSelect: (match: PotentialPluginMatch) => void,
- onCancel: () => void
-}) {
- return (
-
-
-
Multiple Matches Found
-
We found several potential matches for {plugin.name} . Please select the correct one:
-
-
- {potentialMatches.map((match, index) => (
-
-
-
{match.name}
-
Version: {match.version}
- {match.description &&
{match.description}
}
-
- Source: {match.repository}
- {match.download_count && Downloads: {match.download_count.toLocaleString()} }
- MC: {match.minecraft_versions.join(', ')}
-
-
-
-
- ))}
-
-
-
- Cancel
-
-
-
- );
-}
-
-// After the WarningModal component, add this new component
-function PremiumPluginModal({
- pluginName,
- pluginVersion,
- resourceUrl,
- onClose
-}: {
- pluginName: string;
- pluginVersion: string;
- resourceUrl: string;
- onClose: () => void;
-}) {
- return (
-
-
-
Premium Plugin Detected
-
- {pluginName} {pluginVersion} appears to be a premium or protected plugin
- that requires manual download.
-
-
-
To update this plugin:
-
- Visit the plugin page on SpigotMC
- Log in to your SpigotMC account
- Download the latest version manually
- Replace the current plugin file in your server's plugins folder
-
-
-
-
-
- );
-}
+// Import hooks
+import { useServerContext } from './context/ServerContext/useServerContext';
+import { usePluginContext } from './context/PluginContext/usePluginContext';
+import { usePluginActions } from './hooks/usePluginActions';
+/**
+ * The main application component that serves as the entry point.
+ * This component is responsible for setting up the context providers
+ * and rendering the main application structure.
+ */
function App() {
- const [serverPath, setServerPath] = useState("");
- const [serverInfo, setServerInfo] = useState(null);
- const [plugins, setPlugins] = useState([]);
- const [isScanning, setIsScanning] = useState(false);
- const [scanComplete, setScanComplete] = useState(false);
- const [error, setError] = useState(null);
- const [selectedPlugin, setSelectedPlugin] = useState(null);
- const [scanProgress, setScanProgress] = useState(null);
- const [isCheckingUpdates, setIsCheckingUpdates] = useState(false);
- const [updateError, setUpdateError] = useState(null);
- // --- New State Variables ---
- const [pluginLoadingStates, setPluginLoadingStates] = useState>({});
- const [bulkUpdateProgress, setBulkUpdateProgress] = useState(null);
- const [warningMessage, setWarningMessage] = useState(null);
- const [potentialMatches, setPotentialMatches] = useState([]);
- // --- New state for match selector ---
- const [showMatchSelector, setShowMatchSelector] = useState(false);
- const [pluginToDisambiguate, setPluginToDisambiguate] = useState(null);
- // --- End New state for match selector ---
- // --- End New State Variables ---
- const [serverType, setServerType] = useState('Unknown');
- const [premiumPluginInfo, setPremiumPluginInfo] = useState<{name: string; version: string; url: string} | null>(null);
+ console.log("App Component Initialized");
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * Wrapper to ensure PluginProvider has access to ServerContext
+ */
+function PluginContextWrapper() {
+ const { serverPath, serverInfo } = useServerContext();
useEffect(() => {
- let unlistenScanStarted: UnlistenFn | undefined;
- let unlistenScanProgress: UnlistenFn | undefined;
- let unlistenScanCompleted: UnlistenFn | undefined;
- let unlistenScanError: UnlistenFn | undefined;
- let unlistenBulkUpdateStart: UnlistenFn | undefined;
- let unlistenUpdateCheckProgress: UnlistenFn | undefined;
- let unlistenSingleUpdateCheckStarted: UnlistenFn | undefined;
- let unlistenSingleUpdateCheckCompleted: UnlistenFn | undefined;
+ console.log("PluginContextWrapper: serverPath =", serverPath);
+ }, [serverPath]);
- const setupListeners = async () => {
- unlistenScanStarted = await listen("scan_started", () => {
- console.log("Scan started event received");
- setIsScanning(true);
- setScanProgress(null);
- setError(null);
- });
+ return (
+
+
+
+
+
+ );
+}
- unlistenScanProgress = await listen("scan_progress", (event) => {
- console.log("Scan progress event received:", event.payload);
- setScanProgress(event.payload);
- });
+/**
+ * The main application content that uses context hooks.
+ * This is separate from App to ensure the contexts are available.
+ */
+function AppContent() {
+ const {
+ serverInfo,
+ serverPath,
+ isScanning,
+ scanProgress,
+ scanComplete
+ } = useServerContext();
- unlistenScanCompleted = await listen("scan_completed", (event) => {
- console.log("Scan completed event received with payload:", event.payload);
- try {
- console.log("Server info:", event.payload.server_info);
- console.log("Plugins count:", event.payload.plugins.length);
+ const {
+ plugins,
+ selectedPlugin,
+ showPluginDetails,
+ closePluginDetails,
+ isCheckingUpdates,
+ bulkUpdateProgress
+ } = usePluginContext();
- // Update state in a specific order to ensure UI updates properly
- setIsScanning(false);
- setScanComplete(true);
- setServerInfo(event.payload.server_info);
- setPlugins(event.payload.plugins);
- setServerType(event.payload.server_info.server_type);
+ const {
+ warningMessage,
+ clearWarningMessage,
+ downloadProgress,
+ premiumPluginInfo,
+ clearPremiumPluginInfo
+ } = useUIContext();
- // Add a slight delay and verify the state was updated
- setTimeout(() => {
- console.log("Verifying state updates after scan:");
- console.log("- scanComplete:", scanComplete);
- console.log("- serverInfo:", serverInfo);
- console.log("- plugins count:", plugins.length);
-
- // Force a state update if plugins length is still 0 but we got plugins
- if (plugins.length === 0 && event.payload.plugins.length > 0) {
- console.log("Forcing state update because plugins array is empty");
- setPlugins([...event.payload.plugins]);
- }
- }, 100);
-
- console.log("State updated after scan completion");
- } catch (err) {
- console.error("Error handling scan completion:", err);
- setError(`Error handling scan completion: ${err}`);
- setIsScanning(false);
- }
- });
-
- unlistenScanError = await listen("scan_error", (event) => {
- console.log("Scan error event received:", event.payload);
- setIsScanning(false);
- setError(event.payload);
- });
-
- unlistenBulkUpdateStart = await listen("bulk_update_start", (event) => {
- console.log("Bulk update start event received, total plugins:", event.payload);
- setIsCheckingUpdates(true);
- setUpdateError(null);
- setBulkUpdateProgress({
- processed: 0,
- total: event.payload,
- current_plugin_name: "Starting update check..."
- });
- });
-
- unlistenUpdateCheckProgress = await listen("update_check_progress", (event) => {
- console.log("Update check progress event received:", event.payload);
- setBulkUpdateProgress(event.payload);
- });
-
- unlistenSingleUpdateCheckStarted = await listen("single_update_check_started", (event) => {
- console.log("Single update check started for:", event.payload);
- });
-
- unlistenSingleUpdateCheckCompleted = await listen("single_update_check_completed", (event) => {
- console.log("Single update check completed, result:", event.payload);
- const { original_file_path, plugin, error } = event.payload;
-
- setPluginLoadingStates(prev => ({ ...prev, [original_file_path]: false }));
-
- if (error) {
- setUpdateError(`Error checking for updates: ${error}`);
- return;
- }
-
- if (plugin) {
- setPlugins(prevPlugins => prevPlugins.map(p => {
- if (p.file_path === original_file_path) {
- return plugin;
- }
- return p;
- }));
-
- if (serverPath) {
- invoke("save_plugin_data", {
- plugins: plugins.map(p => p.file_path === original_file_path ? plugin : p),
- serverPath
- }).catch(err => {
- console.error("Error saving plugin data after single update:", err);
- });
- }
- }
- });
-
- window.addEventListener('update-plugin', ((e: CustomEvent) => {
- if (e.detail) {
- updatePlugin(e.detail);
- }
- }) as EventListener);
- };
-
- setupListeners();
-
- return () => {
- unlistenScanStarted?.();
- unlistenScanProgress?.();
- unlistenScanCompleted?.();
- unlistenScanError?.();
- unlistenBulkUpdateStart?.();
- unlistenUpdateCheckProgress?.();
- unlistenSingleUpdateCheckStarted?.();
- unlistenSingleUpdateCheckCompleted?.();
- window.removeEventListener('update-plugin', (() => {}) as EventListener);
- };
- }, []);
-
- async function selectDirectory() {
- 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);
- setServerInfo(null);
- setPlugins([]);
- setIsScanning(false);
- setScanComplete(false);
- setError(null);
- setScanProgress(null);
- setIsCheckingUpdates(false);
- setUpdateError(null);
- setPluginLoadingStates({});
- setBulkUpdateProgress(null);
-
- try {
- 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.`);
- setPlugins(loadedPlugins);
- setScanComplete(true);
- setError(null);
- } 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}`);
- }
- }
-
- async function scanForPlugins() {
- if (!serverPath || isScanning) return;
-
- try {
- console.log("Starting scan for plugins in:", serverPath);
- setIsScanning(true);
- setScanComplete(false);
- setPlugins([]);
- setServerInfo(null);
- setScanProgress(null);
- setError(null);
-
- await invoke("scan_server_dir", { path: serverPath });
- console.log("Scan server dir command invoked successfully");
-
- } catch (err) {
- console.error("Error invoking scan command:", err);
- setError(`Failed to start scan: ${err as string}`);
- setIsScanning(false);
- }
- }
-
- async function checkForUpdates() {
- if (!plugins.length || isCheckingUpdates) return;
-
- setIsCheckingUpdates(true);
- setUpdateError(null);
- setBulkUpdateProgress(null);
- console.log("Invoking bulk check_plugin_updates...");
-
- try {
- const repositoriesToCheck = ['SpigotMC', 'Modrinth', 'GitHub'];
-
- 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);
-
- const updatedPlugins = await invoke("check_plugin_updates", {
- plugins: pluginsToSend,
- repositories: repositoriesToCheck,
- });
-
- console.log("Bulk update check completed successfully, updating state.");
- setPlugins(updatedPlugins);
-
- if (serverPath) {
- try {
- console.log("[checkForUpdates] Saving plugin data...");
- await invoke("save_plugin_data", { plugins: updatedPlugins, serverPath });
- 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 (err) {
- const errorMessage = `Error during bulk update check: ${err instanceof Error ? err.message : String(err)}`;
- console.error(errorMessage);
- setUpdateError(errorMessage);
- } finally {
- setIsCheckingUpdates(false);
- setBulkUpdateProgress(null);
- }
- }
-
- async function checkSinglePlugin(plugin: Plugin) {
- if (isScanning || isCheckingUpdates || pluginLoadingStates[plugin.file_path]) return;
-
- console.log(`Invoking single check for: ${plugin.name} (${plugin.file_path})`);
- setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: true }));
- setUpdateError(null);
-
- try {
- const repositoriesToCheck = ['SpigotMC', 'Modrinth', 'GitHub'];
-
- const pluginToSend = {
- name: plugin.name,
- version: plugin.version,
- authors: plugin.authors || [],
- file_path: plugin.file_path,
- file_hash: plugin.file_hash,
- website: plugin.website,
- description: plugin.description,
- api_version: plugin.api_version,
- main_class: plugin.main_class,
- depend: plugin.depend,
- soft_depend: plugin.soft_depend,
- load_before: plugin.load_before,
- commands: plugin.commands,
- permissions: plugin.permissions,
- has_update: plugin.has_update,
- };
-
- await invoke("check_single_plugin_update_command", {
- plugin: pluginToSend,
- repositories: repositoriesToCheck,
- });
- } catch (err) {
- const errorMessage = `Error invoking single update command for ${plugin.name}: ${err instanceof Error ? err.message : String(err)}`;
- console.error(errorMessage);
- setUpdateError(errorMessage);
- setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: false }));
- }
- }
-
- async function updatePlugin(plugin: Plugin) {
- if (!plugin.has_update || !plugin.latest_version || !plugin.repository_source || !plugin.repository_id) {
- setUpdateError(`Cannot update ${plugin.name}: Missing required update information`);
- return;
- }
-
- setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: true }));
- setUpdateError(null);
-
- try {
- console.log(`Updating plugin: ${plugin.name} to version ${plugin.latest_version}`);
-
- // Try to update the plugin
- try {
- const newFilePath = await invoke("update_plugin", {
- pluginId: plugin.repository_id,
- version: plugin.latest_version,
- repository: plugin.repository_source,
- currentFilePath: plugin.file_path,
- serverTypeStr: serverInfo?.server_type
- });
-
- console.log(`Update successful for ${plugin.name}, new file path: ${newFilePath}`);
-
- setPlugins(currentPlugins => currentPlugins.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;
- }));
-
- if (serverPath) {
- await invoke("save_plugin_data", {
- plugins: plugins.map(p => {
- if (p.file_path === plugin.file_path) {
- return {
- ...p,
- version: p.latest_version || p.version,
- has_update: false,
- file_path: newFilePath
- };
- }
- return p;
- }),
- serverPath
- });
- }
- } catch (error: any) {
- console.error(`Error updating plugin: ${error}`);
-
- // Check if this is a premium resource
- const errorMsg = String(error);
- if (errorMsg.startsWith("PREMIUM_RESOURCE:")) {
- // Parse the premium resource error
- const parts = errorMsg.split(":");
- if (parts.length >= 4) {
- const resourceUrl = parts.slice(3).join(":"); // Rejoin in case URL contains colons
-
- // Show premium resource modal
- setPremiumPluginInfo({
- name: plugin.name,
- version: plugin.latest_version,
- url: resourceUrl
- });
- } else {
- setUpdateError(`Premium plugin detected, but couldn't get resource URL: ${plugin.name}`);
- }
- } else {
- // Standard error handling
- setUpdateError(`Failed to update ${plugin.name}: ${error}`);
- }
- }
- } catch (error) {
- console.error(`Error in updatePlugin: ${error}`);
- setUpdateError(`Update process error: ${error}`);
- } finally {
- setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: false }));
- }
- }
-
- const showPluginDetails = (plugin: Plugin) => {
- setSelectedPlugin(plugin);
- };
-
- const closePluginDetails = () => {
- setSelectedPlugin(null);
- };
-
- const handleSelectMatch = async (selectedMatch: PotentialPluginMatch) => {
- if (!pluginToDisambiguate || !serverPath) return;
-
- console.log(`User selected match: ${selectedMatch.name} from ${selectedMatch.repository}`);
- setShowMatchSelector(false);
- setPluginLoadingStates(prev => ({ ...prev, [pluginToDisambiguate.file_path]: true }));
-
- try {
- const updatedPlugin: Plugin = await invoke("set_plugin_repository", {
- pluginFilePath: pluginToDisambiguate.file_path,
- repository: selectedMatch.repository,
- repositoryId: selectedMatch.repository_id,
- pageUrl: selectedMatch.page_url,
- serverPath: serverPath,
- });
-
- setPlugins(currentPlugins =>
- currentPlugins.map(p =>
- p.file_path === updatedPlugin.file_path ? updatedPlugin : p
- )
- );
- console.log(`Successfully set repository source for ${updatedPlugin.name}`);
-
- } catch (err) {
- console.error("Error setting plugin repository source:", err);
- setUpdateError(`Failed to set repository source for ${pluginToDisambiguate.name}: ${err}`);
- } finally {
- setPluginLoadingStates(prev => ({ ...prev, [pluginToDisambiguate.file_path]: false }));
- setPluginToDisambiguate(null);
- setPotentialMatches([]);
- }
- };
-
- const handleCancelMatchSelection = () => {
- setShowMatchSelector(false);
- setPluginToDisambiguate(null);
- setPotentialMatches([]);
- if (pluginToDisambiguate) {
- setPluginLoadingStates(prev => ({ ...prev, [pluginToDisambiguate.file_path]: false }));
- }
- };
+ const { isMatchSelectorOpen, potentialMatches, currentPluginForMatch, handleMatchSelection, closeMatchSelector } = usePluginActions();
return (
+
+
-
-
- Select Server Directory
-
- setServerPath(e.target.value)}
- placeholder="Enter server directory path..."
- />
- Browse
-
-
- {isScanning ? "Scanning..." : "Scan for Plugins"}
-
+
+ {/* Server section with selector and info */}
+
+
+ {!isScanning && serverInfo && }
+
- {isScanning && scanProgress && (
-
-
Scanning: {scanProgress.current_file}
-
-
{scanProgress.processed} / {scanProgress.total}
+ {/* Scan progress - only shown when scanning is in progress */}
+ {/* Removed duplicate ScanProgress component since it's already in ServerSelector */}
+
+ {/* Plugins section - only shown when scan is complete */}
+ {serverPath && scanComplete && plugins.length > 0 && (
+
+
+
Plugins ({plugins.length})
+
+ {bulkUpdateProgress && }
- )}
- {error && (
-
- {error}
+
+
Name
+
Version
+
Compatibility
+
Actions
- )}
-
- {serverInfo && (
-
- )}
-
- {serverInfo && plugins.length > 0 && (
-
-
isLoading) || plugins.length === 0}
- className="action-button update-check-button"
- >
- {isCheckingUpdates ? 'Checking All...' : 'Check All for Updates'}
-
- {isCheckingUpdates && bulkUpdateProgress && (
-
- Checking {bulkUpdateProgress.processed}/{bulkUpdateProgress.total}: {bulkUpdateProgress.current_plugin_name}
-
-
- )}
- {updateError && (
-
{updateError}
- )}
+
)}
- {plugins.length > 0 && (
-
- Installed Plugins ({plugins.length})
-
- Name
- Current Version
- Latest Version
- Actions
-
- {plugins.map((plugin) => (
-
-
{plugin.name}
-
{plugin.version}
-
-
- checkSinglePlugin(plugin)}
- disabled={isScanning || isCheckingUpdates || pluginLoadingStates[plugin.file_path]}
- >
- {pluginLoadingStates[plugin.file_path] ? 'Checking...' : 'Check'}
-
- {plugin.has_update && (
- updatePlugin(plugin)}
- disabled={isScanning || isCheckingUpdates || pluginLoadingStates[plugin.file_path]}
- >
- {pluginLoadingStates[plugin.file_path] ? 'Updating...' : 'Update'}
-
- )}
- showPluginDetails(plugin)}>Info
-
-
- ))}
-
- )}
-
- {scanComplete && plugins.length === 0 && (
-
- Installed Plugins (0)
- No plugins found in this directory.
-
+ {/* No plugins message - only shown when scan complete but no plugins found */}
+ {serverPath && scanComplete && plugins.length === 0 && (
+
)}
+ {/* Modals and Overlays */}
{selectedPlugin && (
-
+
)}
{warningMessage && (
setWarningMessage(null)}
+ isOpen={!!warningMessage}
+ onClose={clearWarningMessage}
+ title="Warning"
+ message={warningMessage.text}
+ variant={warningMessage.type === 'error' ? 'error' :
+ warningMessage.type === 'success' ? 'info' : 'warning'}
/>
)}
- {showMatchSelector && pluginToDisambiguate && (
- 0 && (
+
)}
{premiumPluginInfo && (
setPremiumPluginInfo(null)}
+ isOpen={!!premiumPluginInfo}
+ onClose={clearPremiumPluginInfo}
+ pluginInfo={{
+ name: premiumPluginInfo.name,
+ version: "Unknown",
+ url: "#"
+ }}
/>
)}
-
+{isMatchSelectorOpen && currentPluginForMatch && (
+
+ )}
+
-
+
);
}
-export default App;
+export default App;
\ No newline at end of file
diff --git a/src/components/common/Badge/Badge.css b/src/components/common/Badge/Badge.css
new file mode 100644
index 0000000..c9a4851
--- /dev/null
+++ b/src/components/common/Badge/Badge.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/components/common/Badge/Badge.tsx b/src/components/common/Badge/Badge.tsx
new file mode 100644
index 0000000..5a5f8ed
--- /dev/null
+++ b/src/components/common/Badge/Badge.tsx
@@ -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
= ({
+ 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) => {
+ if (onClick && (event.key === 'Enter' || event.key === ' ')) {
+ event.preventDefault(); // Prevent default space bar scroll
+ onClick();
+ }
+ }, [onClick]);
+
+ return (
+
+ {icon && {icon} }
+ {label}
+
+ );
+};
+
+export default memo(BadgeComponent);
\ No newline at end of file
diff --git a/src/components/common/Button/Button.css b/src/components/common/Button/Button.css
new file mode 100644
index 0000000..24c50e0
--- /dev/null
+++ b/src/components/common/Button/Button.css
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/common/Button/Button.tsx b/src/components/common/Button/Button.tsx
new file mode 100644
index 0000000..cf28ae4
--- /dev/null
+++ b/src/components/common/Button/Button.tsx
@@ -0,0 +1,91 @@
+import React, { memo } from 'react';
+import './Button.css';
+
+export interface ButtonProps extends React.ButtonHTMLAttributes {
+ /**
+ * 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 = ({
+ 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 (
+
+ {isLoading && }
+ {startIcon && !isLoading && {startIcon} }
+ {children}
+ {endIcon && !isLoading && {endIcon} }
+
+ );
+};
+
+export default memo(ButtonComponent);
\ No newline at end of file
diff --git a/src/components/common/Modal/Modal.css b/src/components/common/Modal/Modal.css
new file mode 100644
index 0000000..e31034c
--- /dev/null
+++ b/src/components/common/Modal/Modal.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/components/common/Modal/Modal.tsx b/src/components/common/Modal/Modal.tsx
new file mode 100644
index 0000000..e9f7c9e
--- /dev/null
+++ b/src/components/common/Modal/Modal.tsx
@@ -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 = ({
+ title,
+ children,
+ isOpen,
+ onClose,
+ className = '',
+ showCloseButton = true,
+ closeOnOutsideClick = true
+}) => {
+ const modalRef = useRef(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) => {
+ if (closeOnOutsideClick && modalRef.current && !modalRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
+ {(title || showCloseButton) && (
+
+ {title &&
{title} }
+ {showCloseButton && (
+
+ ×
+
+ )}
+
+ )}
+
+ {children}
+
+
+
+ );
+};
+
+export default Modal;
\ No newline at end of file
diff --git a/src/components/common/NotificationDisplay/NotificationDisplay.css b/src/components/common/NotificationDisplay/NotificationDisplay.css
new file mode 100644
index 0000000..67653db
--- /dev/null
+++ b/src/components/common/NotificationDisplay/NotificationDisplay.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/components/common/NotificationDisplay/NotificationDisplay.tsx b/src/components/common/NotificationDisplay/NotificationDisplay.tsx
new file mode 100644
index 0000000..6c0e778
--- /dev/null
+++ b/src/components/common/NotificationDisplay/NotificationDisplay.tsx
@@ -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 (
+
+ {text}
+ {/* Add a close button only for persistent errors? */}
+ {type === 'error' && (
+
+ ×
+
+ )}
+
+ );
+};
+
+export default NotificationDisplay;
\ No newline at end of file
diff --git a/src/components/common/ProgressBar/ProgressBar.css b/src/components/common/ProgressBar/ProgressBar.css
new file mode 100644
index 0000000..fe8aae4
--- /dev/null
+++ b/src/components/common/ProgressBar/ProgressBar.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/components/common/ProgressBar/ProgressBar.tsx b/src/components/common/ProgressBar/ProgressBar.tsx
new file mode 100644
index 0000000..07de988
--- /dev/null
+++ b/src/components/common/ProgressBar/ProgressBar.tsx
@@ -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 = ({
+ 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 (
+
+ {label &&
{label}
}
+
+
+ {showPercentage && (
+ {percentage}%
+ )}
+
+
+ {showValues && (
+
{formattedValue}
+ )}
+
+ );
+};
+
+export default ProgressBar;
\ No newline at end of file
diff --git a/src/components/layout/Footer/Footer.css b/src/components/layout/Footer/Footer.css
new file mode 100644
index 0000000..c8afef2
--- /dev/null
+++ b/src/components/layout/Footer/Footer.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/components/layout/Footer/Footer.tsx b/src/components/layout/Footer/Footer.tsx
new file mode 100644
index 0000000..290b3aa
--- /dev/null
+++ b/src/components/layout/Footer/Footer.tsx
@@ -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 = ({ 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 (
+
+ );
+};
+
+export default Footer;
\ No newline at end of file
diff --git a/src/components/layout/Header/Header.css b/src/components/layout/Header/Header.css
new file mode 100644
index 0000000..2e30697
--- /dev/null
+++ b/src/components/layout/Header/Header.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx
new file mode 100644
index 0000000..3006f3f
--- /dev/null
+++ b/src/components/layout/Header/Header.tsx
@@ -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 = ({ appVersion = '1.0.0' }) => {
+ const { isCheckingAppUpdate, appUpdateAvailable, checkForAppUpdate } = useAppUpdates();
+ const { serverPath } = useServerContext();
+ const { isCheckingUpdates } = usePluginContext();
+
+ const handleCheckForUpdates = async () => {
+ if (!isCheckingAppUpdate) {
+ await checkForAppUpdate();
+ }
+ };
+
+ return (
+
+
+
PlugSnatcher
+ v{appVersion}
+ {appUpdateAvailable && (
+ Update Available
+ )}
+
+
+
+ {serverPath && (
+
+ Selected Server:
+
+ {serverPath.length > 40 ? `...${serverPath.slice(-40)}` : serverPath}
+
+
+ )}
+
+
+
+
+ {isCheckingAppUpdate ? 'Checking...' : 'Check for Updates'}
+
+
+
+ );
+};
+
+export default Header;
\ No newline at end of file
diff --git a/src/components/layout/MainContent/MainContent.css b/src/components/layout/MainContent/MainContent.css
new file mode 100644
index 0000000..dfd837b
--- /dev/null
+++ b/src/components/layout/MainContent/MainContent.css
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/layout/MainContent/MainContent.tsx b/src/components/layout/MainContent/MainContent.tsx
new file mode 100644
index 0000000..e9b4d7b
--- /dev/null
+++ b/src/components/layout/MainContent/MainContent.tsx
@@ -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 = ({ children }) => {
+ const { warningMessage, clearWarningMessage } = useUIContext();
+
+ return (
+
+ {warningMessage && (
+
+ {warningMessage.text}
+
+ ×
+
+
+ )}
+
+
+ {children}
+
+
+ );
+};
+
+export default MainContent;
\ No newline at end of file
diff --git a/src/components/plugins/NoPluginsMessage/NoPluginsMessage.css b/src/components/plugins/NoPluginsMessage/NoPluginsMessage.css
new file mode 100644
index 0000000..88579ae
--- /dev/null
+++ b/src/components/plugins/NoPluginsMessage/NoPluginsMessage.css
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/plugins/NoPluginsMessage/NoPluginsMessage.tsx b/src/components/plugins/NoPluginsMessage/NoPluginsMessage.tsx
new file mode 100644
index 0000000..b4f9b9b
--- /dev/null
+++ b/src/components/plugins/NoPluginsMessage/NoPluginsMessage.tsx
@@ -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 (
+
+
+
Scanning for plugins...
+
This might take a moment, please wait.
+
+ );
+ }
+
+ if (!serverPath) {
+ return (
+
+
📁
+
No Server Selected
+
Please select a Minecraft server directory to get started.
+
+ );
+ }
+
+ if (scanComplete && !isScanning) {
+ return (
+
+
🔍
+
No Plugins Found
+
We couldn't find any plugins in the selected server directory.
+
Make sure you've selected a valid Minecraft server with plugins installed.
+
+ );
+ }
+
+ // Default message if we're in an intermediate state
+ return (
+
+
🧩
+
Ready to Scan
+
Click "Scan for Plugins" to discover plugins in your server.
+
+ );
+};
+
+export default NoPluginsMessage;
\ No newline at end of file
diff --git a/src/components/plugins/PluginDetails/PluginDetails.css b/src/components/plugins/PluginDetails/PluginDetails.css
new file mode 100644
index 0000000..2ca0d2f
--- /dev/null
+++ b/src/components/plugins/PluginDetails/PluginDetails.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/components/plugins/PluginDetails/PluginDetails.tsx b/src/components/plugins/PluginDetails/PluginDetails.tsx
new file mode 100644
index 0000000..66e4e17
--- /dev/null
+++ b/src/components/plugins/PluginDetails/PluginDetails.tsx
@@ -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 = ({ 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 No dependencies
;
+ }
+
+ return (
+
+ {dependencies.map((dep, index) => (
+
+ {dep}
+
+ ))}
+
+ );
+ };
+
+ const getSizeInMB = (bytes: number | undefined): string => {
+ if (!bytes) return 'Unknown';
+ return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
+ };
+
+ return (
+
+
+
+
+
{plugin.name}
+
+
+ Version: {
+ plugin.repository_url || (plugin.repository_source && plugin.repository_id) ? (
+
+ {plugin.version}
+
+ ) : plugin.version
+ }
+
+ {plugin.has_update && plugin.latest_version && (
+
+
+
+ Latest: {
+ plugin.repository_url || (plugin.repository_source && plugin.repository_id) ? (
+
+ {plugin.latest_version}
+
+ ) : plugin.latest_version
+ }
+
+
+ )}
+
+
+
+
+ ×
+
+
+
+
+
+
Description
+
+ {plugin.description || 'No description available.'}
+
+
+
+
+
+
+
Author Information
+
+ {plugin.authors && plugin.authors.length > 0
+ ? plugin.authors.join(', ')
+ : 'Unknown author'}
+
+ {plugin.website && (
+
+ Visit Website
+
+ )}
+
+
+
+
Dependencies
+ {renderDependencies()}
+
+
+
+
+
+
File Information
+
+ Path:
+ {plugin.file_path}
+
+
+ Hash:
+ {plugin.file_hash.substring(0, 10)}...
+
+ {plugin.repository_source && (
+
+ )}
+ {plugin.platform_compatibility && Object.keys(plugin.platform_compatibility).length > 0 && (
+
+
Compatible with:
+
+ {Object.entries(plugin.platform_compatibility).map(([platform, isCompatible]) => (
+
+ {platform}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ {plugin.has_update ? (
+
+ Update to {plugin.latest_version}
+
+ ) : (
+
+ Check for Updates
+
+ )}
+
+
+
+ );
+};
+
+export default PluginDetails;
\ No newline at end of file
diff --git a/src/components/plugins/PluginItem/PluginItem.css b/src/components/plugins/PluginItem/PluginItem.css
new file mode 100644
index 0000000..e7f8400
--- /dev/null
+++ b/src/components/plugins/PluginItem/PluginItem.css
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/plugins/PluginItem/PluginItem.tsx b/src/components/plugins/PluginItem/PluginItem.tsx
new file mode 100644
index 0000000..8bbe5f0
--- /dev/null
+++ b/src/components/plugins/PluginItem/PluginItem.tsx
@@ -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 = ({ 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 (
+
+ {/* Plugin Name Column */}
+
+
{plugin.name}
+ {plugin.authors && plugin.authors.length > 0 && (
+
+ By {plugin.authors.join(', ')}
+
+ )}
+ {plugin.description && (
+
+ {plugin.description.length > 100
+ ? `${plugin.description.substring(0, 100)}...`
+ : plugin.description}
+
+ )}
+
+
+ {/* Version Column */}
+
+
+ v{plugin.version}
+ {plugin.has_update && plugin.latest_version && (
+ <>
+ →
+ v{plugin.latest_version}
+ >
+ )}
+
+
+
+ {/* Compatibility Column */}
+
+ {compatibility ? (
+
+ {Object.entries(plugin.platform_compatibility || {}).map(([platform, isCompatible]) => (
+
+ {platform}
+
+ ))}
+
+ ) : (
+
Unknown
+ )}
+
+
+ {/* Actions Column */}
+
+
+
+ Info
+
+
+ {plugin.has_update ? (
+
+ Update
+
+ ) : (
+
+ Check
+
+ )}
+
+
+
+ );
+};
+
+export const PluginItem = memo(PluginItemComponent);
\ No newline at end of file
diff --git a/src/components/plugins/PluginList/PluginList.css b/src/components/plugins/PluginList/PluginList.css
new file mode 100644
index 0000000..cbb293e
--- /dev/null
+++ b/src/components/plugins/PluginList/PluginList.css
@@ -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 */
+}
\ No newline at end of file
diff --git a/src/components/plugins/PluginList/PluginList.tsx b/src/components/plugins/PluginList/PluginList.tsx
new file mode 100644
index 0000000..c3f1917
--- /dev/null
+++ b/src/components/plugins/PluginList/PluginList.tsx
@@ -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('');
+ 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 (
+
+
+ setSearchTerm(e.target.value)}
+ className="search-input"
+ />
+ {searchTerm && (
+ setSearchTerm('')}
+ aria-label="Clear search"
+ >
+ ×
+
+ )}
+
+
+
+
+ Sort by:
+ handleSort('name')}
+ >
+ Name{getSortIndicator('name')}
+
+ handleSort('version')}
+ >
+ Version{getSortIndicator('version')}
+
+ handleSort('update')}
+ >
+ Updates{getSortIndicator('update')}
+
+
+
+
+ Showing {filteredPlugins.length} of {plugins.length} plugins
+
+
+
+
+ {sortedPlugins.length > 0 ? (
+ sortedPlugins.map(plugin => (
+
+ ))
+ ) : (
+
+
No plugins match your search.
+
setSearchTerm('')}>Clear search
+
+ )}
+
+
+ );
+};
+
+export default PluginList;
\ No newline at end of file
diff --git a/src/components/server/ScanProgress/ScanProgress.css b/src/components/server/ScanProgress/ScanProgress.css
new file mode 100644
index 0000000..2e0c71e
--- /dev/null
+++ b/src/components/server/ScanProgress/ScanProgress.css
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/server/ScanProgress/ScanProgress.tsx b/src/components/server/ScanProgress/ScanProgress.tsx
new file mode 100644
index 0000000..14a3b29
--- /dev/null
+++ b/src/components/server/ScanProgress/ScanProgress.tsx
@@ -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 = ({ 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 (
+
+
+ Scanning for plugins...
+ {percentage}%
+
+
+
+
+ {progress && (
+
+
+ {progress.current} of {progress.total} files processed
+
+
+ )}
+
+ );
+};
+
+export default ScanProgress;
\ No newline at end of file
diff --git a/src/components/server/ServerInfo/ServerInfo.css b/src/components/server/ServerInfo/ServerInfo.css
new file mode 100644
index 0000000..663b18e
--- /dev/null
+++ b/src/components/server/ServerInfo/ServerInfo.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/components/server/ServerInfo/ServerInfo.tsx b/src/components/server/ServerInfo/ServerInfo.tsx
new file mode 100644
index 0000000..fc4c663
--- /dev/null
+++ b/src/components/server/ServerInfo/ServerInfo.tsx
@@ -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 = ({ 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 (
+
+
+
{getServerTypeIcon()}
+
+
+ {formatServerType(serverInfo.server_type)}
+
+
+
+ Server directory 📂
+
+
+
+
+
+
+ Plugins Directory
+ {serverInfo.plugins_directory}
+
+
+
+ Plugins Found
+ {serverInfo.plugins_count}
+
+
+
+ );
+};
+
+export default ServerInfo;
\ No newline at end of file
diff --git a/src/components/server/ServerSelector/ServerSelector.css b/src/components/server/ServerSelector/ServerSelector.css
new file mode 100644
index 0000000..42b1500
--- /dev/null
+++ b/src/components/server/ServerSelector/ServerSelector.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/components/server/ServerSelector/ServerSelector.tsx b/src/components/server/ServerSelector/ServerSelector.tsx
new file mode 100644
index 0000000..a7cf141
--- /dev/null
+++ b/src/components/server/ServerSelector/ServerSelector.tsx
@@ -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 (
+
+
+
+ Select Server Directory
+
+
+ {serverPath && !isScanning && (
+
+ Scan for Plugins
+
+ )}
+
+ {serverPath && scanComplete && (
+
+ Reset
+
+ )}
+
+
+ {isScanning && (
+ <>
+ {/* Add debug info */}
+
+
Debug - scanProgress: {JSON.stringify(scanProgress, null, 2)}
+
+
+ >
+ )}
+
+ {error && (
+
+ )}
+
+ );
+};
+
+export default ServerSelector;
\ No newline at end of file
diff --git a/src/components/updates/BulkUpdateProgress/BulkUpdateProgress.css b/src/components/updates/BulkUpdateProgress/BulkUpdateProgress.css
new file mode 100644
index 0000000..a62f175
--- /dev/null
+++ b/src/components/updates/BulkUpdateProgress/BulkUpdateProgress.css
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/updates/BulkUpdateProgress/BulkUpdateProgress.tsx b/src/components/updates/BulkUpdateProgress/BulkUpdateProgress.tsx
new file mode 100644
index 0000000..fb8f8d9
--- /dev/null
+++ b/src/components/updates/BulkUpdateProgress/BulkUpdateProgress.tsx
@@ -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 (
+
+
+
+ Checking for Updates
+
+
+ {bulkUpdateProgress.processed} of {bulkUpdateProgress.total}
+
+
+
+
+
+
+
+ Processing: {bulkUpdateProgress.current_plugin_name}
+
+
+
+ );
+};
+
+export default BulkUpdateProgress;
\ No newline at end of file
diff --git a/src/components/updates/CompatibilityCheckDialog/CompatibilityCheckDialog.css b/src/components/updates/CompatibilityCheckDialog/CompatibilityCheckDialog.css
new file mode 100644
index 0000000..fcde578
--- /dev/null
+++ b/src/components/updates/CompatibilityCheckDialog/CompatibilityCheckDialog.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/components/updates/CompatibilityCheckDialog/CompatibilityCheckDialog.tsx b/src/components/updates/CompatibilityCheckDialog/CompatibilityCheckDialog.tsx
new file mode 100644
index 0000000..e140adc
--- /dev/null
+++ b/src/components/updates/CompatibilityCheckDialog/CompatibilityCheckDialog.tsx
@@ -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 = ({
+ 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 (
+
+
+
+ {supportsVersion ? (
+
+
✓
+
+ This plugin update is marked as compatible with your server version ({serverVersion}).
+ It should work correctly after updating.
+
+
+ ) : (
+
+
⚠️
+
+ This plugin update is not explicitly marked as compatible with your server version ({serverVersion}).
+ Updating may cause compatibility issues or server errors.
+
+
+ )}
+
+
+
+
Plugin Details
+
+ Name: {plugin.name}
+ Current Version: {plugin.version}
+ Latest Version: {plugin.latest_version}
+ {plugin.platform_compatibility && (
+
+ Supported Versions: {plugin.platform_compatibility.join(', ')}
+
+ )}
+
+
+
+ {!supportsVersion && (
+
+
+ setAcknowledgedRisk(!acknowledgedRisk)}
+ />
+
+ I understand the risks and still want to update this plugin
+
+
+
+ )}
+
+
+
+ Cancel
+
+
+ Update Anyway
+
+
+
+
+ );
+};
+
+export default CompatibilityCheckDialog;
\ No newline at end of file
diff --git a/src/components/updates/DownloadProgressIndicator/DownloadProgressIndicator.css b/src/components/updates/DownloadProgressIndicator/DownloadProgressIndicator.css
new file mode 100644
index 0000000..f676df0
--- /dev/null
+++ b/src/components/updates/DownloadProgressIndicator/DownloadProgressIndicator.css
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/updates/DownloadProgressIndicator/DownloadProgressIndicator.tsx b/src/components/updates/DownloadProgressIndicator/DownloadProgressIndicator.tsx
new file mode 100644
index 0000000..5766b1b
--- /dev/null
+++ b/src/components/updates/DownloadProgressIndicator/DownloadProgressIndicator.tsx
@@ -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 = ({
+ 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 (
+
+
+
+ {getStatusIcon()}
+ {pluginName}
+ v{version}
+
+
+ {getStatusText()}
+
+
+
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+ );
+};
+
+export default DownloadProgressIndicator;
\ No newline at end of file
diff --git a/src/components/updates/PluginMatchSelector/PluginMatchSelector.css b/src/components/updates/PluginMatchSelector/PluginMatchSelector.css
new file mode 100644
index 0000000..99a95bc
--- /dev/null
+++ b/src/components/updates/PluginMatchSelector/PluginMatchSelector.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/components/updates/PluginMatchSelector/PluginMatchSelector.tsx b/src/components/updates/PluginMatchSelector/PluginMatchSelector.tsx
new file mode 100644
index 0000000..924e882
--- /dev/null
+++ b/src/components/updates/PluginMatchSelector/PluginMatchSelector.tsx
@@ -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 = ({
+ isOpen,
+ onClose,
+ pluginName,
+ potentialMatches,
+ onSelectMatch
+}) => {
+ const [selectedMatchIndex, setSelectedMatchIndex] = useState(null);
+
+ const handleSelectMatch = () => {
+ if (selectedMatchIndex !== null) {
+ onSelectMatch(potentialMatches[selectedMatchIndex]);
+ onClose();
+ }
+ };
+
+ return (
+
+
+
+
+ We found multiple potential matches for {pluginName} .
+ Please select the correct plugin from the list below:
+
+
+
+
+ {potentialMatches.map((match, index) => (
+
setSelectedMatchIndex(index)}
+ >
+
+
{match.name}
+ v{match.version}
+ {match.repository}
+
+
+
+ {match.description && (
+
{match.description}
+ )}
+
+
+ MC: {match.minecraft_versions.join(', ')}
+
+ {match.download_count !== undefined && (
+
+ {new Intl.NumberFormat().format(match.download_count)} downloads
+
+ )}
+
+
+
+
+
+ setSelectedMatchIndex(index)}
+ />
+
+
+
+ ))}
+
+
+
+
+ Cancel
+
+
+ Select Plugin
+
+
+
+
+ );
+};
+
+export default PluginMatchSelector;
\ No newline at end of file
diff --git a/src/components/updates/PremiumPluginModal/PremiumPluginModal.css b/src/components/updates/PremiumPluginModal/PremiumPluginModal.css
new file mode 100644
index 0000000..7296140
--- /dev/null
+++ b/src/components/updates/PremiumPluginModal/PremiumPluginModal.css
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/updates/PremiumPluginModal/PremiumPluginModal.tsx b/src/components/updates/PremiumPluginModal/PremiumPluginModal.tsx
new file mode 100644
index 0000000..2748653
--- /dev/null
+++ b/src/components/updates/PremiumPluginModal/PremiumPluginModal.tsx
@@ -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 = ({
+ 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 (
+
+
+
+
+
+
Premium Plugin Detected
+
+ {pluginInfo.name} (version {pluginInfo.version}) is a premium plugin
+ that requires manual download from the official source.
+
+
+ Click the button below to visit the plugin's website where you can purchase or
+ download the update if you already own it.
+
+
+
+
+
+ Cancel
+
+
+ Open Download Page
+
+
+
+
+ );
+};
+
+export default PremiumPluginModal;
\ No newline at end of file
diff --git a/src/components/updates/UpdateControls/UpdateControls.css b/src/components/updates/UpdateControls/UpdateControls.css
new file mode 100644
index 0000000..00803ad
--- /dev/null
+++ b/src/components/updates/UpdateControls/UpdateControls.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/components/updates/UpdateControls/UpdateControls.tsx b/src/components/updates/UpdateControls/UpdateControls.tsx
new file mode 100644
index 0000000..dbff324
--- /dev/null
+++ b/src/components/updates/UpdateControls/UpdateControls.tsx
@@ -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 (
+
+
+ {isCheckingUpdates ? (
+
+
+
Checking for plugin updates{bulkUpdateProgress ? ` (${bulkUpdateProgress.processed}/${bulkUpdateProgress.total})` : '...'}
+
+ ) : hasUpdates ? (
+
+ {outdatedCount} plugin update{outdatedCount !== 1 ? 's' : ''} available
+
+ ) : updatesHaveBeenChecked ? (
+
All plugins are up to date
+ ) : (
+
Click "Check for Updates" to check plugin versions
+ )}
+
+
+
+ ↻ : undefined}
+ aria-label="Check for plugin updates"
+ >
+ Check for Updates
+
+
+ {hasUpdates && (
+ ↑}
+ aria-label={`Update all ${outdatedCount} plugins`}
+ >
+ Update All ({outdatedCount})
+
+ )}
+
+
+ );
+};
+
+export default UpdateControls;
\ No newline at end of file
diff --git a/src/components/updates/WarningModal/WarningModal.css b/src/components/updates/WarningModal/WarningModal.css
new file mode 100644
index 0000000..a6155c1
--- /dev/null
+++ b/src/components/updates/WarningModal/WarningModal.css
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/components/updates/WarningModal/WarningModal.tsx b/src/components/updates/WarningModal/WarningModal.tsx
new file mode 100644
index 0000000..31901f7
--- /dev/null
+++ b/src/components/updates/WarningModal/WarningModal.tsx
@@ -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 = ({
+ 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 (
+
+
+
+ );
+ case 'info':
+ return (
+
+
+
+ );
+ case 'warning':
+ default:
+ return (
+
+
+
+ );
+ }
+ };
+
+ return (
+
+
+
+ {renderIcon()}
+
+
+
+
+
+ {onConfirm ? (
+ <>
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+ >
+ ) : (
+
+ OK
+
+ )}
+
+
+
+ );
+};
+
+export default WarningModal;
\ No newline at end of file
diff --git a/src/context/PluginContext/PluginContext.tsx b/src/context/PluginContext/PluginContext.tsx
new file mode 100644
index 0000000..485508a
--- /dev/null
+++ b/src/context/PluginContext/PluginContext.tsx
@@ -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;
+
+ /**
+ * 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;
+
+ /**
+ * Function to check for updates for a single plugin
+ */
+ checkSinglePlugin: (plugin: Plugin) => Promise;
+
+ /**
+ * Function to update a plugin to the latest version
+ */
+ updatePlugin: (plugin: Plugin) => Promise;
+
+ /**
+ * 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({} as PluginContextProps);
+
+interface PluginProviderProps {
+ children: ReactNode;
+}
+
+/**
+ * Provider component for managing plugin-related state
+ */
+export const PluginProvider: React.FC = ({
+ children
+}) => {
+ // Get server context directly
+ const { serverPath, serverInfo } = useServerContext();
+ const serverType = serverInfo?.server_type;
+
+ const [plugins, setPluginsState] = useState([]);
+ const [selectedPlugin, setSelectedPlugin] = useState(null);
+ const [isCheckingUpdates, setIsCheckingUpdates] = useState(false);
+ const [updateError, setUpdateError] = useState(null);
+ const [pluginLoadingStates, setPluginLoadingStates] = useState>({});
+ const [bulkUpdateProgress, setBulkUpdateProgress] = useState(null);
+ const [isCheckingSinglePlugin, setIsCheckingSinglePlugin] = useState(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("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("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("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("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 (
+
+ {children}
+
+ );
+};
+
+export default PluginProvider;
\ No newline at end of file
diff --git a/src/context/PluginContext/usePluginContext.ts b/src/context/PluginContext/usePluginContext.ts
new file mode 100644
index 0000000..f8d73cc
--- /dev/null
+++ b/src/context/PluginContext/usePluginContext.ts
@@ -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;
\ No newline at end of file
diff --git a/src/context/ServerContext/ServerContext.tsx b/src/context/ServerContext/ServerContext.tsx
new file mode 100644
index 0000000..3787cac
--- /dev/null
+++ b/src/context/ServerContext/ServerContext.tsx
@@ -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;
+
+ /**
+ * Function to scan for plugins in the selected directory
+ */
+ scanForPlugins: () => Promise;
+
+ /**
+ * 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({} as ServerContextProps);
+
+interface ServerProviderProps {
+ children: ReactNode;
+ onScanComplete?: (result: ScanResult) => void;
+}
+
+/**
+ * Provider component for managing server-related state
+ */
+export const ServerProvider: React.FC = ({
+ children,
+ onScanComplete
+}) => {
+ const [serverPath, setServerPathState] = useState("");
+ const [serverInfo, setServerInfo] = useState(null);
+ const [isScanning, setIsScanning] = useState(false);
+ const [scanComplete, setScanComplete] = useState(false);
+ const [error, setError] = useState(null);
+ const [scanProgress, setScanProgress] = useState(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 (
+
+ {children}
+
+ );
+};
+
+export default ServerProvider;
\ No newline at end of file
diff --git a/src/context/ServerContext/useServerContext.ts b/src/context/ServerContext/useServerContext.ts
new file mode 100644
index 0000000..451ff4d
--- /dev/null
+++ b/src/context/ServerContext/useServerContext.ts
@@ -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;
\ No newline at end of file
diff --git a/src/context/UIContext/UIContext.tsx b/src/context/UIContext/UIContext.tsx
new file mode 100644
index 0000000..3818c1c
--- /dev/null
+++ b/src/context/UIContext/UIContext.tsx
@@ -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(null);
+
+interface UIProviderProps {
+ children: ReactNode;
+}
+
+let messageIdCounter = 0;
+
+export const UIProvider: React.FC = ({ children }) => {
+ // State for warning messages
+ const [warningMessage, setWarningMessage] = useState(null);
+
+ // State for premium plugin info
+ const [premiumPluginInfo, setPremiumPluginInfo] = useState(null);
+
+ // State for download progress
+ const [downloadProgress, setDownloadProgress] = useState(0);
+
+ // State for compatibility dialog
+ const [isCompatibilityDialogOpen, setIsCompatibilityDialogOpen] = useState(false);
+ const [currentPluginForCompatibility, setCurrentPluginForCompatibility] = useState(null);
+
+ // State for update available notification
+ const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
+ const [updateVersion, setUpdateVersion] = useState(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 (
+
+ {children}
+
+ );
+};
+
+export default UIProvider;
\ No newline at end of file
diff --git a/src/context/UIContext/useUIContext.ts b/src/context/UIContext/useUIContext.ts
new file mode 100644
index 0000000..70022a3
--- /dev/null
+++ b/src/context/UIContext/useUIContext.ts
@@ -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;
\ No newline at end of file
diff --git a/src/hooks/useAppUpdates.ts b/src/hooks/useAppUpdates.ts
new file mode 100644
index 0000000..8427c17
--- /dev/null
+++ b/src/hooks/useAppUpdates.ts
@@ -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(false);
+ const [appUpdateAvailable, setAppUpdateAvailable] = useState(false);
+ const [appUpdateVersion, setAppUpdateVersion] = useState(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;
\ No newline at end of file
diff --git a/src/hooks/useEventListeners.ts b/src/hooks/useEventListeners.ts
new file mode 100644
index 0000000..7675302
--- /dev/null
+++ b/src/hooks/useEventListeners.ts
@@ -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;
\ No newline at end of file
diff --git a/src/hooks/usePluginActions.ts b/src/hooks/usePluginActions.ts
new file mode 100644
index 0000000..6badd5c
--- /dev/null
+++ b/src/hooks/usePluginActions.ts
@@ -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(null);
+ const [currentUpdateProgress, setCurrentUpdateProgress] = useState(0);
+
+ // Add state for potential matches
+ const [potentialMatches, setPotentialMatches] = useState([]);
+ const [currentPluginForMatch, setCurrentPluginForMatch] = useState(null);
+ const [isMatchSelectorOpen, setIsMatchSelectorOpen] = useState(false);
+
+ // Create a state object for plugin loading states that we manage locally
+ const [localPluginLoadingStates, setLocalPluginLoadingStates] = useState>({});
+
+ // 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('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[] => {
+ 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;
\ No newline at end of file
diff --git a/src/hooks/useServerActions.ts b/src/hooks/useServerActions.ts
new file mode 100644
index 0000000..dc86d6d
--- /dev/null
+++ b/src/hooks/useServerActions.ts
@@ -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(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;
\ No newline at end of file
diff --git a/src/hooks/useUpdateActions.ts b/src/hooks/useUpdateActions.ts
new file mode 100644
index 0000000..9c2ad86
--- /dev/null
+++ b/src/hooks/useUpdateActions.ts
@@ -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;
\ No newline at end of file
diff --git a/src/types/events.types.ts b/src/types/events.types.ts
new file mode 100644
index 0000000..73b5c0d
--- /dev/null
+++ b/src/types/events.types.ts
@@ -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;
+}
\ No newline at end of file
diff --git a/src/types/plugin.types.ts b/src/types/plugin.types.ts
new file mode 100644
index 0000000..404e732
--- /dev/null
+++ b/src/types/plugin.types.ts
@@ -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;
+}
\ No newline at end of file
diff --git a/src/types/server.types.ts b/src/types/server.types.ts
new file mode 100644
index 0000000..e80ef38
--- /dev/null
+++ b/src/types/server.types.ts
@@ -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;
+}
\ No newline at end of file
diff --git a/src/types/tauri.d.ts b/src/types/tauri.d.ts
new file mode 100644
index 0000000..915a267
--- /dev/null
+++ b/src/types/tauri.d.ts
@@ -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;
+}
+
+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(cmd: string, args?: Record): Promise;
+}
+
+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;
+
+ /**
+ * Installs the available update.
+ * @returns A promise that resolves when the update is installed.
+ */
+ export function installUpdate(): Promise;
+}
+
+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;
+
+ /**
+ * Returns the path to the application configuration directory.
+ * @returns A promise resolving to the app config directory path.
+ */
+ export function appConfigDir(): Promise;
+
+ /**
+ * Returns the path to the application local data directory.
+ * @returns A promise resolving to the app local data directory path.
+ */
+ export function appLocalDataDir(): Promise;
+
+ /**
+ * Returns the path to the application cache directory.
+ * @returns A promise resolving to the app cache directory path.
+ */
+ export function appCacheDir(): Promise;
+
+ /**
+ * Returns the path to the application log directory.
+ * @returns A promise resolving to the app log directory path.
+ */
+ export function appLogDir(): Promise;
+}
+
+declare module '@tauri-apps/api/core/event' {
+ export interface Event {
+ payload: T;
+ windowLabel?: string;
+ event: string;
+ }
+
+ export type EventCallback = (event: Event) => 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(event: string, handler: EventCallback): Promise;
+
+ /**
+ * 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(event: string, handler: EventCallback): Promise;
+}
+
+// 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';
+}
\ No newline at end of file
diff --git a/src/utils/appUpdates.ts b/src/utils/appUpdates.ts
new file mode 100644
index 0000000..3fa4764
--- /dev/null
+++ b/src/utils/appUpdates.ts
@@ -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 {
+ 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 {
+ 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;
+ }
+}
\ No newline at end of file
diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts
new file mode 100644
index 0000000..8abcb26
--- /dev/null
+++ b/src/utils/formatters.ts
@@ -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;
+}
\ No newline at end of file
diff --git a/src/utils/serverUtils.ts b/src/utils/serverUtils.ts
new file mode 100644
index 0000000..8c06161
--- /dev/null
+++ b/src/utils/serverUtils.ts
@@ -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);
+}
\ No newline at end of file
diff --git a/src/utils/validators.ts b/src/utils/validators.ts
new file mode 100644
index 0000000..4508f4c
--- /dev/null
+++ b/src/utils/validators.ts
@@ -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;
+}
\ No newline at end of file