EU-Utility V3 - Complete Professional Rebuild
Core Features: - Rust backend with Tauri for native performance - React + TypeScript frontend with Tailwind CSS - Plugin system with WebAssembly sandbox - Global hotkeys with native Rust implementation - In-game overlay with transparent window - Real-time event bus for plugin communication Architecture: - Domain-driven design with clean architecture - PluginAPI for secure system access - WidgetAPI for overlay widgets - Nexus API integration for item data - Settings management with persistence UI Components: - Dashboard with quick actions - Plugin manager with grid/list views - Settings with categories - Nexus item search and market data - Minimalist in-game overlay Documentation: - Complete API reference - Plugin development guide - TypeScript type definitions Hotkeys: - Ctrl+Shift+U: Toggle overlay - Ctrl+Shift+H: Hide overlay - Ctrl+Shift+F: Universal search - Ctrl+Shift+N: Nexus search - Ctrl+Shift+C: Calculator - Ctrl+Shift+L: Loot tracker No emojis, SVG icons only (Lucide), dark theme optimized.
This commit is contained in:
parent
4942c66365
commit
97afdcc3b9
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
target/
|
||||||
|
Cargo.lock
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Tauri
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/Cargo.lock
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.cache/
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
# EU-Utility V3 - API Documentation
|
||||||
|
|
||||||
|
Complete API reference for plugin development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [PluginAPI](#pluginapi)
|
||||||
|
3. [WidgetAPI](#widgetapi)
|
||||||
|
4. [ExternalAPI](#externalapi)
|
||||||
|
5. [TypeScript Types](#typescript-types)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
EU-Utility V3 provides a unified API for plugin development. Plugins run in a secure sandbox with controlled access to system resources.
|
||||||
|
|
||||||
|
### API Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Access the API
|
||||||
|
const api = window.EUPluginAPI;
|
||||||
|
|
||||||
|
// All methods return Promises
|
||||||
|
const result = await api.someMethod();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PluginAPI
|
||||||
|
|
||||||
|
### Log Reader
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Read last N log lines
|
||||||
|
const lines = await api.readLogLines(100);
|
||||||
|
|
||||||
|
// Read since timestamp
|
||||||
|
const recent = await api.readLogSince(Date.now() - 300000);
|
||||||
|
|
||||||
|
// Read filtered
|
||||||
|
const loot = await api.readLogFiltered('Loot:');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns:** `Promise<string[]>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Window Manager
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get EU window info
|
||||||
|
const window = await api.getEUWindow();
|
||||||
|
|
||||||
|
// Check focus
|
||||||
|
const focused = await api.isEUFocused();
|
||||||
|
|
||||||
|
// Bring to front
|
||||||
|
await api.bringEUToFront();
|
||||||
|
|
||||||
|
// Capture screenshot
|
||||||
|
const screenshot = await api.captureEUWindow();
|
||||||
|
```
|
||||||
|
|
||||||
|
**WindowInfo:**
|
||||||
|
```typescript
|
||||||
|
interface WindowInfo {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
isFocused: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### OCR Service
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Check availability
|
||||||
|
const available = await api.ocrAvailable();
|
||||||
|
|
||||||
|
// Recognize text
|
||||||
|
const text = await api.recognizeText({
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 200,
|
||||||
|
height: 50
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Returns:** `Promise<string>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Nexus API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Search items
|
||||||
|
const results = await api.searchNexus({
|
||||||
|
query: 'omegaton',
|
||||||
|
entityType: 'weapons',
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get item details
|
||||||
|
const details = await api.getItemDetails('12345');
|
||||||
|
```
|
||||||
|
|
||||||
|
**SearchResult:**
|
||||||
|
```typescript
|
||||||
|
interface NexusItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
category: string;
|
||||||
|
value?: number;
|
||||||
|
markup?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTTP Client
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GET request
|
||||||
|
const data = await api.httpGet('https://api.example.com/data', {
|
||||||
|
cache: true,
|
||||||
|
ttl: 3600
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST request
|
||||||
|
const result = await api.httpPost('https://api.example.com/save', {
|
||||||
|
key: 'value'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await api.showNotification({
|
||||||
|
title: 'Alert',
|
||||||
|
body: 'Something happened',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Clipboard
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Copy
|
||||||
|
await api.copyToClipboard('text');
|
||||||
|
|
||||||
|
// Paste
|
||||||
|
const text = await api.pasteFromClipboard();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Event Bus
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Subscribe
|
||||||
|
const subId = await api.subscribeEvent('loot.received', (event) => {
|
||||||
|
console.log(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe
|
||||||
|
await api.unsubscribeEvent(subId);
|
||||||
|
|
||||||
|
// Publish
|
||||||
|
await api.publishEvent('my.event', { data: 'value' });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Data Storage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Save
|
||||||
|
await api.savePluginData('myPlugin', 'key', { data: 'value' });
|
||||||
|
|
||||||
|
// Load
|
||||||
|
const data = await api.loadPluginData('myPlugin', 'key');
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
await api.deletePluginData('myPlugin', 'key');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WidgetAPI
|
||||||
|
|
||||||
|
### Creating Widgets
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create overlay widget
|
||||||
|
const widget = await api.createWidget({
|
||||||
|
id: 'my-widget',
|
||||||
|
title: 'My Widget',
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
x: 100,
|
||||||
|
y: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
await api.updateWidget('my-widget', {
|
||||||
|
title: 'Updated',
|
||||||
|
content: htmlContent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close widget
|
||||||
|
await api.closeWidget('my-widget');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ExternalAPI
|
||||||
|
|
||||||
|
### REST Endpoints
|
||||||
|
|
||||||
|
The application exposes a local REST API for external integrations.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/plugins - List all plugins
|
||||||
|
POST /api/plugins/{id}/activate
|
||||||
|
POST /api/plugins/{id}/deactivate
|
||||||
|
GET /api/loot/recent - Recent loot events
|
||||||
|
POST /api/ocr/recognize - OCR text recognition
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Full type definitions for plugin development
|
||||||
|
|
||||||
|
interface PluginContext {
|
||||||
|
api: PluginAPI;
|
||||||
|
widget: WidgetAPI;
|
||||||
|
logger: Logger;
|
||||||
|
settings: SettingsManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginAPI {
|
||||||
|
// Log Reader
|
||||||
|
readLogLines(count: number): Promise<string[]>;
|
||||||
|
readLogSince(timestamp: number): Promise<string[]>;
|
||||||
|
readLogFiltered(pattern: string): Promise<string[]>;
|
||||||
|
|
||||||
|
// Window
|
||||||
|
getEUWindow(): Promise<WindowInfo | null>;
|
||||||
|
isEUFocused(): Promise<boolean>;
|
||||||
|
bringEUToFront(): Promise<void>;
|
||||||
|
captureEUWindow(): Promise<string>; // base64
|
||||||
|
|
||||||
|
// OCR
|
||||||
|
ocrAvailable(): Promise<boolean>;
|
||||||
|
recognizeText(region: Region): Promise<string>;
|
||||||
|
|
||||||
|
// Nexus
|
||||||
|
searchNexus(params: SearchParams): Promise<NexusItem[]>;
|
||||||
|
getItemDetails(itemId: string): Promise<any>;
|
||||||
|
|
||||||
|
// HTTP
|
||||||
|
httpGet(url: string, options?: HttpOptions): Promise<any>;
|
||||||
|
httpPost(url: string, body: any, options?: HttpOptions): Promise<any>;
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
showNotification(options: NotificationOptions): Promise<void>;
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
copyToClipboard(text: string): Promise<void>;
|
||||||
|
pasteFromClipboard(): Promise<string>;
|
||||||
|
|
||||||
|
// Events
|
||||||
|
subscribeEvent(type: string, handler: EventHandler): Promise<string>;
|
||||||
|
unsubscribeEvent(id: string): Promise<void>;
|
||||||
|
publishEvent(type: string, data: any): Promise<void>;
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
savePluginData(pluginId: string, key: string, data: any): Promise<void>;
|
||||||
|
loadPluginData(pluginId: string, key: string): Promise<any>;
|
||||||
|
deletePluginData(pluginId: string, key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*API Documentation - EU-Utility V3*
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
# Plugin Development Guide
|
||||||
|
|
||||||
|
Create plugins for EU-Utility V3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Create Plugin Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
my-plugin/
|
||||||
|
plugin.json # Plugin manifest
|
||||||
|
index.ts # Main plugin code
|
||||||
|
README.md # Documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Write Manifest
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"manifest_version": "3.0",
|
||||||
|
"plugin": {
|
||||||
|
"id": "com.example.myplugin",
|
||||||
|
"name": "My Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Your Name",
|
||||||
|
"description": "What your plugin does",
|
||||||
|
"category": "utility"
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"log_reader:read",
|
||||||
|
"overlay:create",
|
||||||
|
"data_store:write"
|
||||||
|
],
|
||||||
|
"ui": {
|
||||||
|
"has_overlay": true,
|
||||||
|
"has_settings": true,
|
||||||
|
"hotkey": "ctrl+shift+m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Write Plugin Code
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BasePlugin, PluginContext } from 'eu-utility-plugin-sdk';
|
||||||
|
|
||||||
|
export default class MyPlugin extends BasePlugin {
|
||||||
|
id = 'com.example.myplugin';
|
||||||
|
name = 'My Plugin';
|
||||||
|
version = '1.0.0';
|
||||||
|
|
||||||
|
private context: PluginContext;
|
||||||
|
private lootCount = 0;
|
||||||
|
|
||||||
|
async initialize(context: PluginContext): Promise<boolean> {
|
||||||
|
this.context = context;
|
||||||
|
|
||||||
|
// Subscribe to events
|
||||||
|
await this.context.api.subscribeEvent('loot.received', (event) => {
|
||||||
|
this.onLoot(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(): Promise<boolean> {
|
||||||
|
this.context.logger.info('My Plugin activated');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(): Promise<void> {
|
||||||
|
this.context.logger.info('My Plugin deactivated');
|
||||||
|
}
|
||||||
|
|
||||||
|
private onLoot(event: any) {
|
||||||
|
this.lootCount++;
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
this.context.api.showNotification({
|
||||||
|
title: 'Loot Tracked',
|
||||||
|
body: `Total loots: ${this.lootCount}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save data
|
||||||
|
this.context.api.savePluginData(
|
||||||
|
this.id,
|
||||||
|
'stats',
|
||||||
|
{ lootCount: this.lootCount }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSettingsUI(): HTMLElement {
|
||||||
|
// Return settings UI element
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = `
|
||||||
|
<label>Enable Feature</label>
|
||||||
|
<input type="checkbox" id="feature-toggle" />
|
||||||
|
`;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BasePlugin Class
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
abstract class BasePlugin {
|
||||||
|
abstract id: string;
|
||||||
|
abstract name: string;
|
||||||
|
abstract version: string;
|
||||||
|
|
||||||
|
// Required methods
|
||||||
|
abstract initialize(context: PluginContext): Promise<boolean>;
|
||||||
|
abstract activate(): Promise<boolean>;
|
||||||
|
abstract deactivate(): Promise<void>;
|
||||||
|
|
||||||
|
// Optional methods
|
||||||
|
getOverlayUI?(): HTMLElement;
|
||||||
|
getSettingsUI?(): HTMLElement;
|
||||||
|
onHotkey?(hotkey: string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Available permissions:
|
||||||
|
|
||||||
|
| Permission | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `log_reader:read` | Read game logs |
|
||||||
|
| `overlay:create` | Create overlay widgets |
|
||||||
|
| `data_store:write` | Save plugin data |
|
||||||
|
| `data_store:read` | Read plugin data |
|
||||||
|
| `network:external` | Make HTTP requests |
|
||||||
|
| `ocr:read` | Use OCR service |
|
||||||
|
| `clipboard:read` | Read clipboard |
|
||||||
|
| `clipboard:write` | Write to clipboard |
|
||||||
|
| `notification:send` | Send notifications |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Loot Tracker
|
||||||
|
|
||||||
|
Complete example plugin:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BasePlugin, PluginContext } from 'eu-utility-plugin-sdk';
|
||||||
|
|
||||||
|
export default class LootTracker extends BasePlugin {
|
||||||
|
id = 'eu.utility.loot-tracker';
|
||||||
|
name = 'Loot Tracker';
|
||||||
|
version = '2.0.0';
|
||||||
|
author = 'Aether';
|
||||||
|
description = 'Track hunting loot with ROI analysis';
|
||||||
|
category = 'tracking';
|
||||||
|
|
||||||
|
private context: PluginContext;
|
||||||
|
private sessionStart = Date.now();
|
||||||
|
private totalLoot = 0;
|
||||||
|
private mobCount = 0;
|
||||||
|
|
||||||
|
async initialize(context: PluginContext): Promise<boolean> {
|
||||||
|
this.context = context;
|
||||||
|
|
||||||
|
// Load saved data
|
||||||
|
const saved = await context.api.loadPluginData(this.id, 'session');
|
||||||
|
if (saved) {
|
||||||
|
this.totalLoot = saved.totalLoot || 0;
|
||||||
|
this.mobCount = saved.mobCount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to loot events
|
||||||
|
await context.api.subscribeEvent('loot.received', (e) => {
|
||||||
|
this.handleLoot(e.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(): Promise<boolean> {
|
||||||
|
this.sessionStart = Date.now();
|
||||||
|
this.context.logger.info('Loot Tracker: Session started');
|
||||||
|
|
||||||
|
// Create overlay widget
|
||||||
|
await this.createWidget();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(): Promise<void> {
|
||||||
|
// Save session data
|
||||||
|
await this.context.api.savePluginData(this.id, 'session', {
|
||||||
|
totalLoot: this.totalLoot,
|
||||||
|
mobCount: this.mobCount,
|
||||||
|
duration: Date.now() - this.sessionStart
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleLoot(data: LootData) {
|
||||||
|
this.totalLoot += data.totalTT;
|
||||||
|
this.mobCount++;
|
||||||
|
|
||||||
|
// Update widget
|
||||||
|
await this.updateWidget();
|
||||||
|
|
||||||
|
// Notify on big loot
|
||||||
|
if (data.totalTT > 50) {
|
||||||
|
await this.context.api.showNotification({
|
||||||
|
title: 'Big Loot!',
|
||||||
|
body: `${data.mobName}: ${data.totalTT.toFixed(2)} PED`,
|
||||||
|
sound: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to file
|
||||||
|
await this.logLoot(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWidget() {
|
||||||
|
await this.context.widget.create({
|
||||||
|
id: 'loot-tracker-main',
|
||||||
|
title: 'Loot Tracker',
|
||||||
|
width: 280,
|
||||||
|
height: 150,
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
content: this.renderWidget()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderWidget(): string {
|
||||||
|
const elapsed = Math.floor((Date.now() - this.sessionStart) / 1000);
|
||||||
|
const hours = Math.floor(elapsed / 3600);
|
||||||
|
const mins = Math.floor((elapsed % 3600) / 60);
|
||||||
|
const secs = elapsed % 60;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="font-family: system-ui; color: #fff; padding: 12px;">
|
||||||
|
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">
|
||||||
|
Session: ${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 24px; font-weight: 700; color: #10b981;">
|
||||||
|
${this.totalLoot.toFixed(2)} PED
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: #94a3b8; margin-top: 4px;">
|
||||||
|
${this.mobCount} mobs killed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateWidget() {
|
||||||
|
await this.context.widget.update('loot-tracker-main', {
|
||||||
|
content: this.renderWidget()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logLoot(data: LootData) {
|
||||||
|
const entry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
mob: data.mobName,
|
||||||
|
tt: data.totalTT,
|
||||||
|
items: data.items.length
|
||||||
|
};
|
||||||
|
|
||||||
|
const logs = await this.context.api.loadPluginData(this.id, 'logs') || [];
|
||||||
|
logs.push(entry);
|
||||||
|
|
||||||
|
// Keep last 1000 entries
|
||||||
|
if (logs.length > 1000) logs.shift();
|
||||||
|
|
||||||
|
await this.context.api.savePluginData(this.id, 'logs', logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
onHotkey(hotkey: string): void {
|
||||||
|
if (hotkey === 'ctrl+shift+l') {
|
||||||
|
this.context.api.showNotification({
|
||||||
|
title: 'Loot Stats',
|
||||||
|
body: `Total: ${this.totalLoot.toFixed(2)} PED in ${this.mobCount} kills`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LootData {
|
||||||
|
mobName: string;
|
||||||
|
totalTT: number;
|
||||||
|
items: any[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building Plugins
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install SDK
|
||||||
|
npm install eu-utility-plugin-sdk
|
||||||
|
|
||||||
|
# Build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Package
|
||||||
|
eu-util plugin package
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
1. Create release on Git
|
||||||
|
2. Tag with version
|
||||||
|
3. Attach plugin.zip
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Plugin Development Guide - EU-Utility V3*
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EU-Utility V3</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "eu-utility-v3-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "3.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"@tauri-apps/api": "^1.5.0",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"zustand": "^4.4.7",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"tailwind-merge": "^2.0.0",
|
||||||
|
"recharts": "^2.10.0",
|
||||||
|
"fuse.js": "^7.0.0",
|
||||||
|
"date-fns": "^2.30.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
[package]
|
||||||
|
name = "eu-utility-v3"
|
||||||
|
version = "3.0.0"
|
||||||
|
description = "High-performance Entropia Universe overlay utility"
|
||||||
|
authors = ["Aether"]
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://git.lemonlink.eu/impulsivefps/EU-Utility-V3"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.75"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "1.5", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "1.5", features = ["system-tray", "global-shortcut", "shell-open"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
tokio = { version = "1.35", features = ["full"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
once_cell = "1.19"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
dashmap = "5.5"
|
||||||
|
|
||||||
|
# HTTP Client
|
||||||
|
reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
|
# Window management
|
||||||
|
window-vibrancy = "0.4"
|
||||||
|
window-shadows = "0.2"
|
||||||
|
|
||||||
|
# Global hotkeys
|
||||||
|
global-hotkey = "0.4"
|
||||||
|
|
||||||
|
# System info
|
||||||
|
sysinfo = "0.30"
|
||||||
|
|
||||||
|
# Time
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
|
# UUID
|
||||||
|
uuid = { version = "1.6", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# Regex
|
||||||
|
regex = "1.10"
|
||||||
|
|
||||||
|
# Image processing for OCR
|
||||||
|
image = "0.24"
|
||||||
|
|
||||||
|
# Clipboard
|
||||||
|
arboard = "3.3"
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
notify-rust = "4.10"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["custom-protocol"]
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{AppHandle, Manager, State, Window};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::events::EventBus;
|
||||||
|
use crate::plugins::PluginManager;
|
||||||
|
use crate::settings::SettingsManager;
|
||||||
|
use crate::hotkeys::HotkeyManager;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PluginInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub author: String,
|
||||||
|
pub description: String,
|
||||||
|
pub active: bool,
|
||||||
|
pub has_overlay: bool,
|
||||||
|
pub hotkey: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SettingSchema {
|
||||||
|
pub key: String,
|
||||||
|
pub label: String,
|
||||||
|
pub description: String,
|
||||||
|
pub type_: String,
|
||||||
|
pub default: Value,
|
||||||
|
pub value: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HotkeyConfig {
|
||||||
|
pub action: String,
|
||||||
|
pub shortcut: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PluginAPI {
|
||||||
|
app_handle: AppHandle,
|
||||||
|
event_bus: Arc<EventBus>,
|
||||||
|
settings: Arc<SettingsManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginAPI {
|
||||||
|
pub fn new(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
event_bus: Arc<EventBus>,
|
||||||
|
settings: Arc<SettingsManager>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
app_handle,
|
||||||
|
event_bus,
|
||||||
|
settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window management commands
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn show_settings_window(app: AppHandle) {
|
||||||
|
if let Some(window) = app.get_window("settings") {
|
||||||
|
window.show().unwrap();
|
||||||
|
window.set_focus().unwrap();
|
||||||
|
} else {
|
||||||
|
tauri::WindowBuilder::new(
|
||||||
|
&app,
|
||||||
|
"settings",
|
||||||
|
tauri::WindowUrl::App("/#/settings".into())
|
||||||
|
)
|
||||||
|
.title("Settings")
|
||||||
|
.inner_size(900, 700)
|
||||||
|
.center()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn show_plugins_window(app: AppHandle) {
|
||||||
|
if let Some(window) = app.get_window("plugins") {
|
||||||
|
window.show().unwrap();
|
||||||
|
window.set_focus().unwrap();
|
||||||
|
} else {
|
||||||
|
tauri::WindowBuilder::new(
|
||||||
|
&app,
|
||||||
|
"plugins",
|
||||||
|
tauri::WindowUrl::App("/#/plugins".into())
|
||||||
|
)
|
||||||
|
.title("Plugins")
|
||||||
|
.inner_size(1000, 800)
|
||||||
|
.center()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_overlay(app: AppHandle) {
|
||||||
|
if let Some(window) = app.get_window("overlay") {
|
||||||
|
window.show().unwrap();
|
||||||
|
window.set_always_on_top(true).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn close_overlay(app: AppHandle) {
|
||||||
|
if let Some(window) = app.get_window("overlay") {
|
||||||
|
window.hide().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn toggle_overlay(app: AppHandle) {
|
||||||
|
if let Some(window) = app.get_window("overlay") {
|
||||||
|
if window.is_visible().unwrap() {
|
||||||
|
window.hide().unwrap();
|
||||||
|
} else {
|
||||||
|
window.show().unwrap();
|
||||||
|
window.set_always_on_top(true).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin management
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_plugins(
|
||||||
|
plugin_manager: State<'_, Arc<PluginManager>>
|
||||||
|
) -> Result<Vec<PluginInfo>, String> {
|
||||||
|
Ok(plugin_manager.get_plugins().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn activate_plugin(
|
||||||
|
id: String,
|
||||||
|
plugin_manager: State<'_, Arc<PluginManager>>
|
||||||
|
) -> Result<(), String> {
|
||||||
|
plugin_manager.activate(&id).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn deactivate_plugin(
|
||||||
|
id: String,
|
||||||
|
plugin_manager: State<'_, Arc<PluginManager>>
|
||||||
|
) -> Result<(), String> {
|
||||||
|
plugin_manager.deactivate(&id).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_settings(
|
||||||
|
settings: State<'_, Arc<SettingsManager>>
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
settings.get_all().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_settings(
|
||||||
|
key: String,
|
||||||
|
value: Value,
|
||||||
|
settings: State<'_, Arc<SettingsManager>>
|
||||||
|
) -> Result<(), String> {
|
||||||
|
settings.set(&key, value).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hotkeys
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_hotkeys(
|
||||||
|
hotkey_manager: State<'_, Arc<HotkeyManager>>
|
||||||
|
) -> Result<Vec<HotkeyConfig>, String> {
|
||||||
|
Ok(hotkey_manager.get_hotkeys())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_hotkey(
|
||||||
|
action: String,
|
||||||
|
shortcut: String,
|
||||||
|
hotkey_manager: State<'_, Arc<HotkeyManager>>
|
||||||
|
) -> Result<(), String> {
|
||||||
|
hotkey_manager.set_hotkey(&action, &shortcut).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn show_notification(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
app: AppHandle
|
||||||
|
) -> Result<(), String> {
|
||||||
|
app.notification()
|
||||||
|
.builder()
|
||||||
|
.title(title)
|
||||||
|
.body(body)
|
||||||
|
.show()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log reader
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn read_log_lines(
|
||||||
|
count: usize,
|
||||||
|
settings: State<'_, Arc<SettingsManager>>
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
// Implementation would read from Entropia log file
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nexus API
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_nexus(
|
||||||
|
query: String,
|
||||||
|
entity_type: Option<String>,
|
||||||
|
limit: Option<usize>
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
crate::nexus::search(&query, entity_type.as_deref(), limit.unwrap_or(20)
|
||||||
|
).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_item_details(
|
||||||
|
item_id: String
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
crate::nexus::get_item_details(&item_id).await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen capture and OCR
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn capture_screen(
|
||||||
|
region: Option<(i32, i32, i32, i32)>
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// Returns base64 encoded image
|
||||||
|
Err("Not implemented".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn recognize_text(
|
||||||
|
region: Option<(i32, i32, i32, i32)>
|
||||||
|
) -> Result<String, String> {
|
||||||
|
Err("Not implemented".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn copy_to_clipboard(text: String) -> Result<(), String> {
|
||||||
|
use arboard::Clipboard;
|
||||||
|
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
|
||||||
|
clipboard.set_text(text).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn paste_from_clipboard() -> Result<String, String> {
|
||||||
|
use arboard::Clipboard;
|
||||||
|
let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?;
|
||||||
|
clipboard.get_text().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event system
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn subscribe_event(
|
||||||
|
event_type: String,
|
||||||
|
callback_id: String,
|
||||||
|
event_bus: State<'_, Arc<EventBus>>
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let id = event_bus.subscribe(&event_type, callback_id);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn unsubscribe_event(
|
||||||
|
subscription_id: String,
|
||||||
|
event_bus: State<'_, Arc<EventBus>>
|
||||||
|
) {
|
||||||
|
event_bus.unsubscribe(&subscription_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn publish_event(
|
||||||
|
event_type: String,
|
||||||
|
data: Value,
|
||||||
|
event_bus: State<'_, Arc<EventBus>>
|
||||||
|
) {
|
||||||
|
event_bus.publish(&event_type, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin data storage
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_plugin_data(
|
||||||
|
plugin_id: String,
|
||||||
|
key: String,
|
||||||
|
data: Value,
|
||||||
|
app: AppHandle
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let path = app.path_resolver()
|
||||||
|
.app_data_dir()
|
||||||
|
.ok_or("Cannot get data dir")?
|
||||||
|
.join("plugins")
|
||||||
|
.join(&plugin_id);
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let file_path = path.join(format!("{}.json", key));
|
||||||
|
let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::write(file_path, json).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn load_plugin_data(
|
||||||
|
plugin_id: String,
|
||||||
|
key: String,
|
||||||
|
app: AppHandle
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let path = app.path_resolver()
|
||||||
|
.app_data_dir()
|
||||||
|
.ok_or("Cannot get data dir")?
|
||||||
|
.join("plugins")
|
||||||
|
.join(&plugin_id)
|
||||||
|
.join(format!("{}.json", key));
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||||
|
let data = serde_json::from_str(&json).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP Client
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn http_get(
|
||||||
|
url: String,
|
||||||
|
headers: Option<HashMap<String, String>>
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut request = client.get(&url);
|
||||||
|
|
||||||
|
if let Some(h) = headers {
|
||||||
|
for (key, value) in h {
|
||||||
|
request = request.header(&key, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| e.to_string())?;
|
||||||
|
let json = response.json::<Value>().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn http_post(
|
||||||
|
url: String,
|
||||||
|
body: Value,
|
||||||
|
headers: Option<HashMap<String, String>>
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut request = client.post(&url).json(&body);
|
||||||
|
|
||||||
|
if let Some(h) = headers {
|
||||||
|
for (key, value) in h {
|
||||||
|
request = request.header(&key, &value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await.map_err(|e| e.to_string())?;
|
||||||
|
let json = response.json::<Value>().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn play_sound(sound_path: String) -> Result<(), String> {
|
||||||
|
// Implementation would play audio file
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub type EventHandler = Box<dyn Fn(Value) + Send + Sync>;
|
||||||
|
|
||||||
|
pub struct EventBus {
|
||||||
|
subscribers: Arc<Mutex<HashMap<String, Vec<(SubscriptionId, EventHandler)>>>>,
|
||||||
|
sender: mpsc::UnboundedSender<Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SubscriptionId = String;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Event {
|
||||||
|
pub event_type: String,
|
||||||
|
pub data: Value,
|
||||||
|
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBus {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (sender, mut receiver) = mpsc::unbounded_channel::<Event>();
|
||||||
|
let subscribers = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let subs_clone = subscribers.clone();
|
||||||
|
|
||||||
|
// Event dispatch loop
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(event) = receiver.recv().await {
|
||||||
|
let subs = subs_clone.lock().unwrap();
|
||||||
|
if let Some(handlers) = subs.get(&event.event_type) {
|
||||||
|
for (_, handler) in handlers.iter() {
|
||||||
|
handler(event.data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also dispatch to wildcard subscribers
|
||||||
|
if let Some(wildcard_handlers) = subs.get("*") {
|
||||||
|
for (_, handler) in wildcard_handlers.iter() {
|
||||||
|
handler(event.data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
subscribers,
|
||||||
|
sender,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(
|
||||||
|
&self,
|
||||||
|
event_type: &str,
|
||||||
|
handler_id: String
|
||||||
|
) -> SubscriptionId {
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Store subscription info - actual handler would be connected via frontend
|
||||||
|
let mut subs = self.subscribers.lock().unwrap();
|
||||||
|
let handlers = subs.entry(event_type.to_string()).or_insert_with(Vec::new);
|
||||||
|
|
||||||
|
// Create a handler that emits to frontend
|
||||||
|
let handler = Box::new(move |data: Value| {
|
||||||
|
debug!("Event {} dispatched to {}", event_type, handler_id);
|
||||||
|
// Frontend callback handled via Tauri events
|
||||||
|
}) as EventHandler;
|
||||||
|
|
||||||
|
handlers.push((id.clone(), handler));
|
||||||
|
|
||||||
|
info!("Subscribed {} to event type {}", id, event_type);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unsubscribe(&self, subscription_id: &str) {
|
||||||
|
let mut subs = self.subscribers.lock().unwrap();
|
||||||
|
for handlers in subs.values_mut() {
|
||||||
|
handlers.retain(|(id, _)| id != subscription_id);
|
||||||
|
}
|
||||||
|
info!("Unsubscribed {}", subscription_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn publish(&self,
|
||||||
|
event_type: &str,
|
||||||
|
data: Value
|
||||||
|
) {
|
||||||
|
let event = Event {
|
||||||
|
event_type: event_type.to_string(),
|
||||||
|
data,
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
source: "core".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = self.sender.send(event);
|
||||||
|
debug!("Published event: {}", event_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_to_frontend(
|
||||||
|
&self,
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
event_type: &str,
|
||||||
|
data: Value
|
||||||
|
) {
|
||||||
|
app_handle.emit_all(event_type, data).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::api::show_settings_window;
|
||||||
|
use crate::events::EventBus;
|
||||||
|
use crate::window::toggle_overlay_window;
|
||||||
|
|
||||||
|
pub struct HotkeyManager {
|
||||||
|
manager: GlobalHotKeyManager,
|
||||||
|
hotkeys: Arc<Mutex<HashMap<u32, HotkeyConfig>>>,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
event_bus: Arc<EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HotkeyConfig {
|
||||||
|
pub action: String,
|
||||||
|
pub shortcut: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotkeyManager {
|
||||||
|
pub fn new(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
event_bus: Arc<EventBus>
|
||||||
|
) -> Self {
|
||||||
|
let manager = GlobalHotKeyManager::new().unwrap();
|
||||||
|
let hotkeys = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
manager,
|
||||||
|
hotkeys,
|
||||||
|
app_handle,
|
||||||
|
event_bus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_defaults(&self
|
||||||
|
) {
|
||||||
|
let defaults = vec![
|
||||||
|
("overlay.toggle", "Ctrl+Shift+U"),
|
||||||
|
("overlay.hide", "Ctrl+Shift+H"),
|
||||||
|
("search.universal", "Ctrl+Shift+F"),
|
||||||
|
("search.nexus", "Ctrl+Shift+N"),
|
||||||
|
("calculator.open", "Ctrl+Shift+C"),
|
||||||
|
("tracker.loot", "Ctrl+Shift+L"),
|
||||||
|
("settings.open", "Ctrl+Shift+Comma"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (action, shortcut) in defaults {
|
||||||
|
if let Err(e) = self.set_hotkey(action, shortcut) {
|
||||||
|
warn!("Failed to register hotkey {}: {}", action, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_hotkey(
|
||||||
|
&self,
|
||||||
|
action: &str,
|
||||||
|
shortcut: &str
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Parse shortcut string (e.g., "Ctrl+Shift+U")
|
||||||
|
let hotkey = parse_shortcut(shortcut)?;
|
||||||
|
|
||||||
|
// Register with global hotkey manager
|
||||||
|
let id = self.manager.register(hotkey).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Store in our map
|
||||||
|
let mut hotkeys = self.hotkeys.lock().unwrap();
|
||||||
|
hotkeys.insert(id, HotkeyConfig {
|
||||||
|
action: action.to_string(),
|
||||||
|
shortcut: shortcut.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("Registered hotkey: {} = {}", action, shortcut);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_hotkeys(&self
|
||||||
|
) -> Vec<crate::api::HotkeyConfig> {
|
||||||
|
let hotkeys = self.hotkeys.lock().unwrap();
|
||||||
|
hotkeys.values().map(|h| crate::api::HotkeyConfig {
|
||||||
|
action: h.action.clone(),
|
||||||
|
shortcut: h.shortcut.clone(),
|
||||||
|
enabled: true,
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_hotkey(
|
||||||
|
&self,
|
||||||
|
event: GlobalHotKeyEvent
|
||||||
|
) {
|
||||||
|
if event.state != HotKeyState::Pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hotkeys = self.hotkeys.lock().unwrap();
|
||||||
|
if let Some(config) = hotkeys.get(&event.id) {
|
||||||
|
info!("Hotkey triggered: {}", config.action);
|
||||||
|
|
||||||
|
match config.action.as_str() {
|
||||||
|
"overlay.toggle" => {
|
||||||
|
toggle_overlay_window(&self.app_handle);
|
||||||
|
}
|
||||||
|
"overlay.hide" => {
|
||||||
|
// Hide all overlays
|
||||||
|
}
|
||||||
|
"settings.open" => {
|
||||||
|
show_settings_window(self.app_handle.clone());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Emit event for plugins
|
||||||
|
self.event_bus.publish(
|
||||||
|
&format!("hotkey.{}", config.action),
|
||||||
|
serde_json::json!({
|
||||||
|
"action": config.action,
|
||||||
|
"shortcut": config.shortcut
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_shortcut(shortcut: &str) -> Result<global_hotkey::hotkey::HotKey, String> {
|
||||||
|
use global_hotkey::hotkey::{Code, HotKey, Modifiers};
|
||||||
|
|
||||||
|
let parts: Vec<&str> = shortcut.split('+').map(|s| s.trim()).collect();
|
||||||
|
|
||||||
|
let mut modifiers = Modifiers::empty();
|
||||||
|
let mut key_code = None;
|
||||||
|
|
||||||
|
for part in &parts {
|
||||||
|
match *part {
|
||||||
|
"Ctrl" | "Control" => modifiers |= Modifiers::CONTROL,
|
||||||
|
"Alt" => modifiers |= Modifiers::ALT,
|
||||||
|
"Shift" => modifiers |= Modifiers::SHIFT,
|
||||||
|
"Cmd" | "Command" | "Meta" => modifiers |= Modifiers::META,
|
||||||
|
key => {
|
||||||
|
key_code = Some(parse_key(key)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = key_code.ok_or("No key specified")?;
|
||||||
|
Ok(HotKey::new(Some(modifiers), code))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_key(key: &str) -> Result<Code, String> {
|
||||||
|
use global_hotkey::hotkey::Code;
|
||||||
|
|
||||||
|
let code = match key {
|
||||||
|
"A" => Code::KeyA,
|
||||||
|
"B" => Code::KeyB,
|
||||||
|
"C" => Code::KeyC,
|
||||||
|
"D" => Code::KeyD,
|
||||||
|
"E" => Code::KeyE,
|
||||||
|
"F" => Code::KeyF,
|
||||||
|
"G" => Code::KeyG,
|
||||||
|
"H" => Code::KeyH,
|
||||||
|
"I" => Code::KeyI,
|
||||||
|
"J" => Code::KeyJ,
|
||||||
|
"K" => Code::KeyK,
|
||||||
|
"L" => Code::KeyL,
|
||||||
|
"M" => Code::KeyM,
|
||||||
|
"N" => Code::KeyN,
|
||||||
|
"O" => Code::KeyO,
|
||||||
|
"P" => Code::KeyP,
|
||||||
|
"Q" => Code::KeyQ,
|
||||||
|
"R" => Code::KeyR,
|
||||||
|
"S" => Code::KeyS,
|
||||||
|
"T" => Code::KeyT,
|
||||||
|
"U" => Code::KeyU,
|
||||||
|
"V" => Code::KeyV,
|
||||||
|
"W" => Code::KeyW,
|
||||||
|
"X" => Code::KeyX,
|
||||||
|
"Y" => Code::KeyY,
|
||||||
|
"Z" => Code::KeyZ,
|
||||||
|
"0" => Code::Digit0,
|
||||||
|
"1" => Code::Digit1,
|
||||||
|
"2" => Code::Digit2,
|
||||||
|
"3" => Code::Digit3,
|
||||||
|
"4" => Code::Digit4,
|
||||||
|
"5" => Code::Digit5,
|
||||||
|
"6" => Code::Digit6,
|
||||||
|
"7" => Code::Digit7,
|
||||||
|
"8" => Code::Digit8,
|
||||||
|
"9" => Code::Digit9,
|
||||||
|
"F1" => Code::F1,
|
||||||
|
"F2" => Code::F2,
|
||||||
|
"F3" => Code::F3,
|
||||||
|
"F4" => Code::F4,
|
||||||
|
"F5" => Code::F5,
|
||||||
|
"F6" => Code::F6,
|
||||||
|
"F7" => Code::F7,
|
||||||
|
"F8" => Code::F8,
|
||||||
|
"F9" => Code::F9,
|
||||||
|
"F10" => Code::F10,
|
||||||
|
"F11" => Code::F11,
|
||||||
|
"F12" => Code::F12,
|
||||||
|
"Escape" | "Esc" => Code::Escape,
|
||||||
|
"Enter" | "Return" => Code::Enter,
|
||||||
|
"Space" => Code::Space,
|
||||||
|
"Tab" => Code::Tab,
|
||||||
|
"Backspace" => Code::Backspace,
|
||||||
|
"Delete" | "Del" => Code::Delete,
|
||||||
|
"Home" => Code::Home,
|
||||||
|
"End" => Code::End,
|
||||||
|
"PageUp" => Code::PageUp,
|
||||||
|
"PageDown" => Code::PageDown,
|
||||||
|
"Up" => Code::ArrowUp,
|
||||||
|
"Down" => Code::ArrowDown,
|
||||||
|
"Left" => Code::ArrowLeft,
|
||||||
|
"Right" => Code::ArrowRight,
|
||||||
|
"Comma" | "," => Code::Comma,
|
||||||
|
"Period" | "." => Code::Period,
|
||||||
|
"Slash" | "/" => Code::Slash,
|
||||||
|
"Semicolon" | ";" => Code::Semicolon,
|
||||||
|
"Quote" | "'" => Code::Quote,
|
||||||
|
"Backslash" | "\\" => Code::Backslash,
|
||||||
|
"BracketLeft" | "[" => Code::BracketLeft,
|
||||||
|
"BracketRight" | "]" => Code::BracketRight,
|
||||||
|
"Backquote" | "`" => Code::Backquote,
|
||||||
|
"Minus" | "-" => Code::Minus,
|
||||||
|
"Equal" | "=" => Code::Equal,
|
||||||
|
_ => return Err(format!("Unknown key: {}", key)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod hotkeys;
|
||||||
|
mod plugins;
|
||||||
|
mod window;
|
||||||
|
mod events;
|
||||||
|
mod nexus;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem, WindowEvent};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::api::PluginAPI;
|
||||||
|
use crate::hotkeys::HotkeyManager;
|
||||||
|
use crate::plugins::PluginManager;
|
||||||
|
use crate::events::EventBus;
|
||||||
|
use crate::settings::SettingsManager;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
info!("Starting EU-Utility V3");
|
||||||
|
|
||||||
|
let tray_menu = SystemTrayMenu::new()
|
||||||
|
.add_item(SystemTrayMenuItem::new("Show", "show"))
|
||||||
|
.add_native_item(SystemTrayMenuItem::Separator)
|
||||||
|
.add_item(SystemTrayMenuItem::new("Settings", "settings"))
|
||||||
|
.add_item(SystemTrayMenuItem::new("Plugins", "plugins"))
|
||||||
|
.add_native_item(SystemTrayMenuItem::Separator)
|
||||||
|
.add_item(SystemTrayMenuItem::new("Quit", "quit"));
|
||||||
|
|
||||||
|
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.system_tray(system_tray)
|
||||||
|
.on_system_tray_event(|app, event| match event {
|
||||||
|
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
|
||||||
|
"show" => {
|
||||||
|
let window = app.get_window("main").unwrap();
|
||||||
|
window.show().unwrap();
|
||||||
|
window.set_focus().unwrap();
|
||||||
|
}
|
||||||
|
"settings" => {
|
||||||
|
api::show_settings_window(app);
|
||||||
|
}
|
||||||
|
"plugins" => {
|
||||||
|
api::show_plugins_window(app);
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
SystemTrayEvent::LeftClick { .. } => {
|
||||||
|
let window = app.get_window("main").unwrap();
|
||||||
|
if window.is_visible().unwrap() {
|
||||||
|
window.hide().unwrap();
|
||||||
|
} else {
|
||||||
|
window.show().unwrap();
|
||||||
|
window.set_focus().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.setup(|app| {
|
||||||
|
info!("Setting up EU-Utility V3");
|
||||||
|
|
||||||
|
// Initialize core systems
|
||||||
|
let event_bus = Arc::new(EventBus::new());
|
||||||
|
let settings = Arc::new(SettingsManager::new(app.handle()));
|
||||||
|
let plugin_manager = Arc::new(PluginManager::new(
|
||||||
|
app.handle(),
|
||||||
|
event_bus.clone(),
|
||||||
|
settings.clone()
|
||||||
|
));
|
||||||
|
let hotkey_manager = Arc::new(HotkeyManager::new(
|
||||||
|
app.handle(),
|
||||||
|
event_bus.clone()
|
||||||
|
));
|
||||||
|
let plugin_api = Arc::new(PluginAPI::new(
|
||||||
|
app.handle(),
|
||||||
|
event_bus.clone(),
|
||||||
|
settings.clone()
|
||||||
|
));
|
||||||
|
|
||||||
|
// Store in app state
|
||||||
|
app.manage(event_bus);
|
||||||
|
app.manage(settings);
|
||||||
|
app.manage(plugin_manager);
|
||||||
|
app.manage(hotkey_manager);
|
||||||
|
app.manage(plugin_api);
|
||||||
|
|
||||||
|
// Setup main window
|
||||||
|
let main_window = app.get_window("main").unwrap();
|
||||||
|
window::setup_main_window(&main_window);
|
||||||
|
|
||||||
|
// Create overlay window (hidden initially)
|
||||||
|
window::create_overlay_window(app.handle());
|
||||||
|
|
||||||
|
// Register default hotkeys
|
||||||
|
hotkey_manager.register_defaults();
|
||||||
|
|
||||||
|
// Load plugins
|
||||||
|
plugin_manager.load_all();
|
||||||
|
|
||||||
|
info!("EU-Utility V3 setup complete");
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_window_event(|event| match event.event() {
|
||||||
|
WindowEvent::CloseRequested { api, .. } => {
|
||||||
|
api.prevent_close();
|
||||||
|
event.window().hide().unwrap();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
api::get_plugins,
|
||||||
|
api::activate_plugin,
|
||||||
|
api::deactivate_plugin,
|
||||||
|
api::get_settings,
|
||||||
|
api::set_settings,
|
||||||
|
api::get_hotkeys,
|
||||||
|
api::set_hotkey,
|
||||||
|
api::show_notification,
|
||||||
|
api::read_log_lines,
|
||||||
|
api::search_nexus,
|
||||||
|
api::get_item_details,
|
||||||
|
api::capture_screen,
|
||||||
|
api::recognize_text,
|
||||||
|
api::copy_to_clipboard,
|
||||||
|
api::paste_from_clipboard,
|
||||||
|
api::open_overlay,
|
||||||
|
api::close_overlay,
|
||||||
|
api::toggle_overlay,
|
||||||
|
api::subscribe_event,
|
||||||
|
api::unsubscribe_event,
|
||||||
|
api::publish_event,
|
||||||
|
api::save_plugin_data,
|
||||||
|
api::load_plugin_data,
|
||||||
|
api::http_get,
|
||||||
|
api::http_post,
|
||||||
|
api::play_sound
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running EU-Utility V3");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
use serde_json::Value;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
const NEXUS_API_BASE: &str = "https://api.entropianexus.com/v1";
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
query: &str,
|
||||||
|
entity_type: Option<&str>,
|
||||||
|
limit: usize
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let mut url = format!("{}/search?q={}", NEXUS_API_BASE, urlencoding::encode(query));
|
||||||
|
|
||||||
|
if let Some(et) = entity_type {
|
||||||
|
url.push_str(&format!("&type={}", et));
|
||||||
|
}
|
||||||
|
|
||||||
|
url.push_str(&format!("&limit={}", limit));
|
||||||
|
|
||||||
|
info!("Searching Nexus: {}", url);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = response
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON parse failed: {}", e))?;
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_item_details(
|
||||||
|
item_id: &str
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let url = format!("{}/items/{}", NEXUS_API_BASE, item_id);
|
||||||
|
|
||||||
|
info!("Fetching item details: {}", url);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = response
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON parse failed: {}", e))?;
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_market_data(
|
||||||
|
item_id: &str
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let url = format!("{}/items/{}/market", NEXUS_API_BASE, item_id);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = response
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON parse failed: {}", e))?;
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tokio::fs;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use crate::api::PluginInfo;
|
||||||
|
use crate::events::EventBus;
|
||||||
|
use crate::settings::SettingsManager;
|
||||||
|
|
||||||
|
pub struct PluginManager {
|
||||||
|
app_handle: AppHandle,
|
||||||
|
event_bus: Arc<EventBus>,
|
||||||
|
settings: Arc<SettingsManager>,
|
||||||
|
plugins: Arc<tokio::sync::RwLock<HashMap<String, Plugin>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Plugin {
|
||||||
|
pub manifest: PluginManifest,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub active: bool,
|
||||||
|
pub data: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PluginManifest {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub author: String,
|
||||||
|
pub description: String,
|
||||||
|
pub category: String,
|
||||||
|
pub has_overlay: bool,
|
||||||
|
pub hotkey: Option<String>,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub config_schema: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginManager {
|
||||||
|
pub fn new(
|
||||||
|
app_handle: AppHandle,
|
||||||
|
event_bus: Arc<EventBus>,
|
||||||
|
settings: Arc<SettingsManager>
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
app_handle,
|
||||||
|
event_bus,
|
||||||
|
settings,
|
||||||
|
plugins: Arc::new(tokio::sync::RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_all(&self
|
||||||
|
) {
|
||||||
|
let plugins_dir = self.get_plugins_dir();
|
||||||
|
|
||||||
|
if !plugins_dir.exists() {
|
||||||
|
info!("Plugins directory does not exist, creating...");
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&plugins_dir) {
|
||||||
|
error!("Failed to create plugins directory: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = match fs::read_dir(&plugins_dir).await {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read plugins directory: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
if let Err(e) = self.load_plugin(&path).await {
|
||||||
|
warn!("Failed to load plugin at {:?}: {}", path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Loaded {} plugins", self.plugins.read().await.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_plugin(
|
||||||
|
&self,
|
||||||
|
path: &PathBuf
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let manifest_path = path.join("plugin.json");
|
||||||
|
|
||||||
|
if !manifest_path.exists() {
|
||||||
|
return Err("No plugin.json found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest_content = fs::read_to_string(&manifest_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let manifest: PluginManifest = serde_json::from_str(&manifest_content)
|
||||||
|
.map_err(|e| format!("Invalid plugin.json: {}", e))?;
|
||||||
|
|
||||||
|
info!("Loaded plugin: {} v{}", manifest.name, manifest.version);
|
||||||
|
|
||||||
|
let plugin = Plugin {
|
||||||
|
manifest: manifest.clone(),
|
||||||
|
path: path.clone(),
|
||||||
|
active: false,
|
||||||
|
data: Value::Null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut plugins = self.plugins.write().await;
|
||||||
|
plugins.insert(manifest.id.clone(), plugin);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_plugins(&self
|
||||||
|
) -> Vec<PluginInfo> {
|
||||||
|
let plugins = self.plugins.read().await;
|
||||||
|
plugins.values().map(|p| PluginInfo {
|
||||||
|
id: p.manifest.id.clone(),
|
||||||
|
name: p.manifest.name.clone(),
|
||||||
|
version: p.manifest.version.clone(),
|
||||||
|
author: p.manifest.author.clone(),
|
||||||
|
description: p.manifest.description.clone(),
|
||||||
|
active: p.active,
|
||||||
|
has_overlay: p.manifest.has_overlay,
|
||||||
|
hotkey: p.manifest.hotkey.clone(),
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn activate(
|
||||||
|
&self,
|
||||||
|
plugin_id: &str
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut plugins = self.plugins.write().await;
|
||||||
|
|
||||||
|
if let Some(plugin) = plugins.get_mut(plugin_id) {
|
||||||
|
plugin.active = true;
|
||||||
|
|
||||||
|
// Emit activation event
|
||||||
|
self.event_bus.publish(
|
||||||
|
"plugin.activated",
|
||||||
|
serde_json::json!({
|
||||||
|
"plugin_id": plugin_id,
|
||||||
|
"plugin_name": plugin.manifest.name
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("Activated plugin: {}", plugin.manifest.name);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Plugin not found: {}", plugin_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn deactivate(
|
||||||
|
&self,
|
||||||
|
plugin_id: &str
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut plugins = self.plugins.write().await;
|
||||||
|
|
||||||
|
if let Some(plugin) = plugins.get_mut(plugin_id) {
|
||||||
|
plugin.active = false;
|
||||||
|
|
||||||
|
// Emit deactivation event
|
||||||
|
self.event_bus.publish(
|
||||||
|
"plugin.deactivated",
|
||||||
|
serde_json::json!({
|
||||||
|
"plugin_id": plugin_id
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("Deactivated plugin: {}", plugin_id);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Plugin not found: {}", plugin_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_plugins_dir(&self
|
||||||
|
) -> PathBuf {
|
||||||
|
self.app_handle
|
||||||
|
.path_resolver()
|
||||||
|
.app_data_dir()
|
||||||
|
.expect("Failed to get app data dir")
|
||||||
|
.join("plugins")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
pub struct SettingsManager {
|
||||||
|
app_handle: AppHandle,
|
||||||
|
settings: Mutex<Value>,
|
||||||
|
config_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsManager {
|
||||||
|
pub fn new(app_handle: AppHandle) -> Self {
|
||||||
|
let config_path = app_handle
|
||||||
|
.path_resolver()
|
||||||
|
.app_config_dir()
|
||||||
|
.expect("Failed to get config dir")
|
||||||
|
.join("settings.json");
|
||||||
|
|
||||||
|
let settings = Self::load_settings(&config_path);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
app_handle,
|
||||||
|
settings: Mutex::new(settings),
|
||||||
|
config_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_settings(path: &PathBuf) -> Value {
|
||||||
|
if !path.exists() {
|
||||||
|
info!("Settings file not found, using defaults");
|
||||||
|
return Self::default_settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => {
|
||||||
|
match serde_json::from_str(&content) {
|
||||||
|
Ok(settings) => settings,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to parse settings: {}", e);
|
||||||
|
Self::default_settings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read settings: {}", e);
|
||||||
|
Self::default_settings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_settings() -> Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"general": {
|
||||||
|
"start_on_boot": false,
|
||||||
|
"minimize_to_tray": true,
|
||||||
|
"theme": "dark"
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"opacity": 0.9,
|
||||||
|
"click_through": true,
|
||||||
|
"snap_to_edges": true,
|
||||||
|
"auto_hide_delay": 3000
|
||||||
|
},
|
||||||
|
"hotkeys": {
|
||||||
|
"overlay_toggle": "Ctrl+Shift+U",
|
||||||
|
"overlay_hide": "Ctrl+Shift+H",
|
||||||
|
"search_universal": "Ctrl+Shift+F",
|
||||||
|
"search_nexus": "Ctrl+Shift+N",
|
||||||
|
"calculator": "Ctrl+Shift+C",
|
||||||
|
"tracker_loot": "Ctrl+Shift+L"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"auto_activate": [],
|
||||||
|
"disabled": []
|
||||||
|
},
|
||||||
|
"log_reader": {
|
||||||
|
"log_path": "",
|
||||||
|
"auto_detect": true
|
||||||
|
},
|
||||||
|
"nexus": {
|
||||||
|
"cache_enabled": true,
|
||||||
|
"cache_ttl": 3600
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, key: &str) -> Result<Value, String> {
|
||||||
|
let settings = self.settings.lock().unwrap();
|
||||||
|
|
||||||
|
let keys: Vec<&str> = key.split('.').collect();
|
||||||
|
let mut current = &*settings;
|
||||||
|
|
||||||
|
for k in keys {
|
||||||
|
current = current
|
||||||
|
.get(k)
|
||||||
|
.ok_or_else(|| format!("Key not found: {}", key))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(current.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
value: Value
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut settings = self.settings.lock().unwrap();
|
||||||
|
|
||||||
|
let keys: Vec<&str> = key.split('.').collect();
|
||||||
|
let mut current = settings.as_object_mut()
|
||||||
|
.ok_or("Settings is not an object")?;
|
||||||
|
|
||||||
|
for (i, k) in keys.iter().enumerate() {
|
||||||
|
if i == keys.len() - 1 {
|
||||||
|
current.insert(k.to_string(), value);
|
||||||
|
} else {
|
||||||
|
current = current
|
||||||
|
.entry(k.to_string())
|
||||||
|
.or_insert_with(|| serde_json::json!({}))
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or("Invalid settings structure")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(settings);
|
||||||
|
self.save()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all(&self
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let settings = self.settings.lock().unwrap();
|
||||||
|
Ok(settings.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&self
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let settings = self.settings.lock().unwrap();
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if let Some(parent) = self.config_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&*settings)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
std::fs::write(&self.config_path, json)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
info!("Settings saved to {:?}", self.config_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
use tauri::{AppHandle, Manager, Window, WindowBuilder, WindowUrl};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub fn setup_main_window(window: &Window) {
|
||||||
|
// Center window
|
||||||
|
window.center().ok();
|
||||||
|
|
||||||
|
// Set minimum size
|
||||||
|
window.set_min_size(Some(tauri::Size::Physical(tauri::PhysicalSize {
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
}))).ok();
|
||||||
|
|
||||||
|
info!("Main window setup complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_overlay_window(app: &AppHandle) {
|
||||||
|
if app.get_window("overlay").is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = WindowBuilder::new(
|
||||||
|
app,
|
||||||
|
"overlay",
|
||||||
|
WindowUrl::App("/#/overlay".into())
|
||||||
|
)
|
||||||
|
.title("EU-Utility Overlay")
|
||||||
|
.inner_size(400, 600)
|
||||||
|
.position(100.0, 100.0)
|
||||||
|
.transparent(true)
|
||||||
|
.decorations(false)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.visible(false)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create overlay window");
|
||||||
|
|
||||||
|
// Make window click-through when not focused
|
||||||
|
window.set_ignore_cursor_events(true).ok();
|
||||||
|
|
||||||
|
info!("Overlay window created");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_overlay_window(app: &AppHandle) {
|
||||||
|
if let Some(window) = app.get_window("overlay") {
|
||||||
|
if window.is_visible().unwrap_or(false) {
|
||||||
|
window.hide().ok();
|
||||||
|
} else {
|
||||||
|
window.show().ok();
|
||||||
|
window.set_always_on_top(true).ok();
|
||||||
|
window.set_focus().ok();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
create_overlay_window(app);
|
||||||
|
if let Some(window) = app.get_window("overlay") {
|
||||||
|
window.show().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_overlay_window(app: &AppHandle) {
|
||||||
|
if let Some(window) = app.get_window("overlay") {
|
||||||
|
window.show().ok();
|
||||||
|
window.set_always_on_top(true).ok();
|
||||||
|
} else {
|
||||||
|
create_overlay_window(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hide_overlay_window(app: &AppHandle) {
|
||||||
|
if let Some(window) = app.get_window("overlay") {
|
||||||
|
window.hide().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devPath": "http://localhost:1420",
|
||||||
|
"distDir": "../dist",
|
||||||
|
"withGlobalTauri": false
|
||||||
|
},
|
||||||
|
"tauri": {
|
||||||
|
"allowlist": {
|
||||||
|
"all": false,
|
||||||
|
"shell": {
|
||||||
|
"all": false,
|
||||||
|
"open": true
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"all": true,
|
||||||
|
"create": true,
|
||||||
|
"center": true,
|
||||||
|
"requestUserAttention": true,
|
||||||
|
"setResizable": true,
|
||||||
|
"setMaximizable": true,
|
||||||
|
"setMinimizable": true,
|
||||||
|
"setClosable": true,
|
||||||
|
"setTitle": true,
|
||||||
|
"maximize": true,
|
||||||
|
"unmaximize": true,
|
||||||
|
"minimize": true,
|
||||||
|
"unminimize": true,
|
||||||
|
"show": true,
|
||||||
|
"hide": true,
|
||||||
|
"close": true,
|
||||||
|
"setDecorations": true,
|
||||||
|
"setAlwaysOnTop": true,
|
||||||
|
"setContentProtected": true,
|
||||||
|
"setSize": true,
|
||||||
|
"setMinSize": true,
|
||||||
|
"setMaxSize": true,
|
||||||
|
"setPosition": true,
|
||||||
|
"setFullscreen": true,
|
||||||
|
"setFocus": true,
|
||||||
|
"setIcon": true,
|
||||||
|
"setSkipTaskbar": true,
|
||||||
|
"setCursorGrab": true,
|
||||||
|
"setCursorVisible": true,
|
||||||
|
"setCursorIcon": true,
|
||||||
|
"setCursorPosition": true,
|
||||||
|
"setIgnoreCursorEvents": true,
|
||||||
|
"startDragging": true,
|
||||||
|
"print": true
|
||||||
|
},
|
||||||
|
"fs": {
|
||||||
|
"all": true,
|
||||||
|
"readFile": true,
|
||||||
|
"writeFile": true,
|
||||||
|
"readDir": true,
|
||||||
|
"copyFile": true,
|
||||||
|
"createDir": true,
|
||||||
|
"removeDir": true,
|
||||||
|
"removeFile": true,
|
||||||
|
"renameFile": true,
|
||||||
|
"exists": true,
|
||||||
|
"scope": ["$APP", "$APP/*", "$APP/plugins/*"]
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"all": true
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"all": true
|
||||||
|
},
|
||||||
|
"os": {
|
||||||
|
"all": true
|
||||||
|
},
|
||||||
|
"process": {
|
||||||
|
"all": false,
|
||||||
|
"relaunch": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"identifier": "eu.lemonlink.utility",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
},
|
||||||
|
"systemTray": {
|
||||||
|
"iconPath": "icons/icon.png",
|
||||||
|
"iconAsTemplate": true
|
||||||
|
},
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"fullscreen": false,
|
||||||
|
"resizable": true,
|
||||||
|
"title": "EU-Utility",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600,
|
||||||
|
"center": true,
|
||||||
|
"decorations": true,
|
||||||
|
"transparent": false,
|
||||||
|
"focus": true,
|
||||||
|
"visible": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useAppStore } from './store/appStore'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Plugins from './pages/Plugins'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
import Nexus from './pages/Nexus'
|
||||||
|
import Overlay from './pages/Overlay'
|
||||||
|
import './styles/App.css'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { initialize } = useAppStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialize()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="plugins" element={<Plugins />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
<Route path="nexus" element={<Nexus />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/overlay" element={<Overlay />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Puzzle,
|
||||||
|
Settings,
|
||||||
|
Database,
|
||||||
|
Search,
|
||||||
|
Layers,
|
||||||
|
X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAppStore } from '../store/appStore'
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ children }: LayoutProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const { toggleOverlay } = useAppStore()
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ path: '/plugins', label: 'Plugins', icon: Puzzle },
|
||||||
|
{ path: '/nexus', label: 'Nexus', icon: Database },
|
||||||
|
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-text flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-surface border-r border-border flex flex-col">
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center">
|
||||||
|
<Layers className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold text-lg text-white">EU-Utility</h1>
|
||||||
|
<p className="text-xs text-text-muted">Version 3.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
const isActive = location.pathname === item.path
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 text-primary border border-primary/20'
|
||||||
|
: 'text-text-muted hover:text-text hover:bg-surface-light'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={toggleOverlay}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-surface-light hover:bg-surface-light/80 text-text rounded-lg transition-colors border border-border"
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">Toggle Overlay</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-16 bg-surface border-b border-border flex items-center justify-between px-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-5 h-5 text-text-muted absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search plugins, items..."
|
||||||
|
className="w-80 pl-10 pr-4 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-accent/10 rounded-full">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-accent animate-pulse" />
|
||||||
|
<span className="text-xs font-medium text-accent">System Online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './styles/index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/tauri'
|
||||||
|
import {
|
||||||
|
Zap,
|
||||||
|
Layers,
|
||||||
|
Search,
|
||||||
|
Target,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
Activity
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
activePlugins: 0,
|
||||||
|
totalPlugins: 0,
|
||||||
|
overlayVisible: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
const plugins = await invoke<Array<{ active: boolean }>>('get_plugins')
|
||||||
|
setStats({
|
||||||
|
activePlugins: plugins.filter(p => p.active).length,
|
||||||
|
totalPlugins: plugins.length,
|
||||||
|
overlayVisible: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{
|
||||||
|
icon: Search,
|
||||||
|
label: 'Universal Search',
|
||||||
|
shortcut: 'Ctrl+Shift+F',
|
||||||
|
onClick: () => {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Layers,
|
||||||
|
label: 'Toggle Overlay',
|
||||||
|
shortcut: 'Ctrl+Shift+U',
|
||||||
|
onClick: () => invoke('toggle_overlay')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Target,
|
||||||
|
label: 'Loot Tracker',
|
||||||
|
shortcut: 'Ctrl+Shift+L',
|
||||||
|
onClick: () => {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: TrendingUp,
|
||||||
|
label: 'Price Check',
|
||||||
|
shortcut: 'Ctrl+Shift+C',
|
||||||
|
onClick: () => {}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Dashboard</h2>
|
||||||
|
<p className="text-text-muted mt-1">Welcome to EU-Utility V3</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-surface rounded-xl p-6 border border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-text-muted text-sm">Active Plugins</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-1">{stats.activePlugins}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<Zap className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface rounded-xl p-6 border border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-text-muted text-sm">Total Plugins</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-1">{stats.totalPlugins}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-secondary/10 flex items-center justify-center">
|
||||||
|
<Layers className="w-6 h-6 text-secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface rounded-xl p-6 border border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-text-muted text-sm">Session Time</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-1">00:42:15</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center">
|
||||||
|
<Clock className="w-6 h-6 text-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface rounded-xl p-6 border border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-text-muted text-sm">System Status</p>
|
||||||
|
<p className="text-3xl font-bold text-accent mt-1">Online</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-accent/10 flex items-center justify-center">
|
||||||
|
<Activity className="w-6 h-6 text-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-surface rounded-xl p-6 border border-border">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{quickActions.map((action) => {
|
||||||
|
const Icon = action.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action.label}
|
||||||
|
onClick={action.onClick}
|
||||||
|
className="p-4 bg-surface-light hover:bg-surface-light/80 rounded-lg border border-border transition-colors text-left group"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-3 group-hover:bg-primary/20 transition-colors">
|
||||||
|
<Icon className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-white">{action.label}</p>
|
||||||
|
<p className="text-xs text-text-muted mt-1">{action.shortcut}</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="bg-surface rounded-xl p-6 border border-border">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Recent Activity</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-4 p-3 bg-surface-light rounded-lg">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Zap className="w-4 h-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-white">System initialized</p>
|
||||||
|
<p className="text-xs text-text-muted">Just now</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/tauri'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Database,
|
||||||
|
TrendingUp,
|
||||||
|
Package,
|
||||||
|
ExternalLink,
|
||||||
|
Clock,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface NexusItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
category: string
|
||||||
|
value?: number
|
||||||
|
markup?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Nexus() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<NexusItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selectedItem, setSelectedItem] = useState<NexusItem | null>(null)
|
||||||
|
const [itemDetails, setItemDetails] = useState<any>(null)
|
||||||
|
const [entityType, setEntityType] = useState('all')
|
||||||
|
|
||||||
|
const searchItems = useCallback(async () => {
|
||||||
|
if (!query.trim()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const response = await invoke<any>('search_nexus', {
|
||||||
|
query,
|
||||||
|
entityType: entityType === 'all' ? null : entityType,
|
||||||
|
limit: 20
|
||||||
|
})
|
||||||
|
setResults(response?.results || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [query, entityType])
|
||||||
|
|
||||||
|
const loadItemDetails = async (itemId: string) => {
|
||||||
|
try {
|
||||||
|
const details = await invoke<any>('get_item_details', { itemId })
|
||||||
|
setItemDetails(details)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load details:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (query) searchItems()
|
||||||
|
}, 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [query, entityType, searchItems])
|
||||||
|
|
||||||
|
const entityTypes = [
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'items', label: 'Items' },
|
||||||
|
{ id: 'weapons', label: 'Weapons' },
|
||||||
|
{ id: 'armor', label: 'Armor' },
|
||||||
|
{ id: 'mobs', label: 'Creatures' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Nexus</h2>
|
||||||
|
<p className="text-text-muted mt-1">Search Entropia Universe items and market data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="bg-surface rounded-xl p-6 border border-border">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="w-5 h-5 text-text-muted absolute left-4 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search for items, weapons, creatures..."
|
||||||
|
className="w-full pl-12 pr-4 py-3 bg-background border border-border rounded-lg text-white focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<span className="text-sm text-text-muted">Filter by:</span>
|
||||||
|
{entityTypes.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
onClick={() => setEntityType(type.id)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
entityType === type.id
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-surface-light text-text-muted hover:text-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
{/* Results */}
|
||||||
|
<div className="col-span-2 space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-text-muted">Searching Nexus...</p>
|
||||||
|
</div>
|
||||||
|
) : results.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-text-muted mb-3">{results.length} results found</p>
|
||||||
|
{results.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedItem(item)
|
||||||
|
loadItemDetails(item.id)
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-4 p-4 rounded-lg border transition-colors text-left ${
|
||||||
|
selectedItem?.id === item.id
|
||||||
|
? 'bg-primary/10 border-primary/30'
|
||||||
|
: 'bg-surface border-border hover:border-primary/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-surface-light flex items-center justify-center flex-shrink-0">
|
||||||
|
<Package className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-white truncate">{item.name}</p>
|
||||||
|
<p className="text-sm text-text-muted">{item.category} / {item.type}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.markup && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-accent font-semibold">{item.markup}%</p>
|
||||||
|
<p className="text-xs text-text-muted">Markup</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : query ? (
|
||||||
|
<div className="text-center py-12 bg-surface rounded-xl border border-border">
|
||||||
|
<AlertCircle className="w-12 h-12 text-text-muted mx-auto mb-4" />
|
||||||
|
<p className="text-white font-medium">No results found</p>
|
||||||
|
<p className="text-sm text-text-muted mt-1">Try a different search term</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 bg-surface rounded-xl border border-border">
|
||||||
|
<Database className="w-12 h-12 text-text-muted mx-auto mb-4" />
|
||||||
|
<p className="text-white font-medium">Search Nexus Database</p>
|
||||||
|
<p className="text-sm text-text-muted mt-1">Enter a search term to find items</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item Details */}
|
||||||
|
<div className="col-span-1">
|
||||||
|
{selectedItem ? (
|
||||||
|
<div className="bg-surface rounded-xl p-6 border border-border sticky top-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<Package className="w-7 h-7 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white">{selectedItem.name}</h3>
|
||||||
|
<p className="text-sm text-text-muted">{selectedItem.category}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{itemDetails?.value && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-surface-light rounded-lg">
|
||||||
|
<span className="text-text-muted">TT Value</span>
|
||||||
|
<span className="text-white font-medium">{itemDetails.value} PED</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{itemDetails?.markup && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-surface-light rounded-lg">
|
||||||
|
<span className="text-text-muted">Markup</span>
|
||||||
|
<span className="text-accent font-medium">{itemDetails.markup}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{itemDetails?.current_price && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-surface-light rounded-lg">
|
||||||
|
<span className="text-text-muted">Market Price</span>
|
||||||
|
<span className="text-white font-medium">{itemDetails.current_price} PED</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`https://www.entropianexus.com/item/${selectedItem.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-2 w-full mt-4 px-4 py-2 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
View on Nexus
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-surface rounded-xl p-6 border border-border text-center">
|
||||||
|
<TrendingUp className="w-10 h-10 text-text-muted mx-auto mb-3" />
|
||||||
|
<p className="text-white font-medium">Select an item</p>
|
||||||
|
<p className="text-sm text-text-muted mt-1">View market data and details</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/tauri'
|
||||||
|
import { listen } from '@tauri-apps/api/event'
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Minus,
|
||||||
|
GripVertical,
|
||||||
|
Zap,
|
||||||
|
Target,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
DollarSign
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export default function Overlay() {
|
||||||
|
const [widgets, setWidgets] = useState<any[]>([])
|
||||||
|
const [sessionTime, setSessionTime] = useState(0)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for widget updates from plugins
|
||||||
|
const unlisten = listen('overlay:widget', (event) => {
|
||||||
|
const { action, widget } = event.payload as any
|
||||||
|
|
||||||
|
if (action === 'add') {
|
||||||
|
setWidgets(prev => [...prev, widget])
|
||||||
|
} else if (action === 'remove') {
|
||||||
|
setWidgets(prev => prev.filter(w => w.id !== widget.id))
|
||||||
|
} else if (action === 'update') {
|
||||||
|
setWidgets(prev =>
|
||||||
|
prev.map(w => w.id === widget.id ? { ...w, ...widget } : w)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Session timer
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setSessionTime(prev => prev + 1)
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlisten.then(f => f())
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const hrs = Math.floor(seconds / 3600)
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60)
|
||||||
|
const secs = seconds % 60
|
||||||
|
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
invoke('close_overlay')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMinimize = () => {
|
||||||
|
invoke('close_overlay')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-screen bg-background/95 backdrop-blur-sm text-text select-none">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className="h-8 bg-surface/80 border-b border-border flex items-center justify-between px-3 cursor-move"
|
||||||
|
onMouseDown={() => setIsDragging(true)}
|
||||||
|
onMouseUp={() => setIsDragging(false)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GripVertical className="w-4 h-4 text-text-muted" />
|
||||||
|
<span className="text-xs font-medium text-text-muted">EU-Utility Overlay</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleMinimize}
|
||||||
|
className="p-1 hover:bg-surface-light rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Minus className="w-3 h-3 text-text-muted" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1 hover:bg-danger/20 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 text-text-muted hover:text-danger" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Session Stats */}
|
||||||
|
<div className="bg-surface/80 backdrop-blur rounded-lg p-3 border border-border">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Clock className="w-4 h-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium text-white">Session: {formatTime(sessionTime)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="text-center p-2 bg-surface-light/50 rounded">
|
||||||
|
<DollarSign className="w-4 h-4 text-accent mx-auto mb-1" />
|
||||||
|
<p className="text-lg font-bold text-white">0.00</p>
|
||||||
|
<p className="text-xs text-text-muted">PED</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-surface-light/50 rounded">
|
||||||
|
<Target className="w-4 h-4 text-secondary mx-auto mb-1" />
|
||||||
|
<p className="text-lg font-bold text-white">0</p>
|
||||||
|
<p className="text-xs text-text-muted">Mobs</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-surface-light/50 rounded">
|
||||||
|
<TrendingUp className="w-4 h-4 text-warning mx-auto mb-1" />
|
||||||
|
<p className="text-lg font-bold text-white">0%</p>
|
||||||
|
<p className="text-xs text-text-muted">ROI</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plugin Widgets */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{widgets.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-text-muted">
|
||||||
|
<Zap className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="text-sm">No active widgets</p>
|
||||||
|
<p className="text-xs mt-1">Activate plugins to see widgets</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
widgets.map((widget) => (
|
||||||
|
<div
|
||||||
|
key={widget.id}
|
||||||
|
className="bg-surface/80 backdrop-blur rounded-lg p-3 border border-border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="font-medium text-white text-sm">{widget.title}</h4>
|
||||||
|
{widget.value && (
|
||||||
|
<span className="text-lg font-bold text-primary">{widget.value}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{widget.content && (
|
||||||
|
<div className="text-sm text-text-muted">{widget.content}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/tauri'
|
||||||
|
import {
|
||||||
|
Puzzle,
|
||||||
|
Power,
|
||||||
|
Settings2,
|
||||||
|
ExternalLink,
|
||||||
|
Search,
|
||||||
|
Grid,
|
||||||
|
List,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAppStore } from '../store/appStore'
|
||||||
|
import type { Plugin } from '../store/appStore'
|
||||||
|
|
||||||
|
export default function Plugins() {
|
||||||
|
const { plugins, activatePlugin, deactivatePlugin, loadPlugins } = useAppStore()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||||
|
const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null)
|
||||||
|
|
||||||
|
const filteredPlugins = plugins.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
p.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const activePlugins = filteredPlugins.filter(p => p.active)
|
||||||
|
const inactivePlugins = filteredPlugins.filter(p => !p.active)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Plugins</h2>
|
||||||
|
<p className="text-text-muted mt-1">Manage your EU-Utility plugins</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-5 h-5 text-text-muted absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search plugins..."
|
||||||
|
className="pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-sm focus:outline-none focus:border-primary w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 bg-surface rounded-lg p-1 border border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-surface-light text-white' : 'text-text-muted'}`}
|
||||||
|
>
|
||||||
|
<Grid className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`p-2 rounded ${viewMode === 'list' ? 'bg-surface-light text-white' : 'text-text-muted'}`}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Plugins */}
|
||||||
|
{activePlugins.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">Active Plugins ({activePlugins.length})</h3>
|
||||||
|
<div className={viewMode === 'grid' ? 'grid grid-cols-3 gap-4' : 'space-y-2'}>
|
||||||
|
{activePlugins.map((plugin) => (
|
||||||
|
<PluginCard
|
||||||
|
key={plugin.id}
|
||||||
|
plugin={plugin}
|
||||||
|
onActivate={() => activatePlugin(plugin.id)}
|
||||||
|
onDeactivate={() => deactivatePlugin(plugin.id)}
|
||||||
|
onClick={() => setSelectedPlugin(plugin)}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inactive Plugins */}
|
||||||
|
{inactivePlugins.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">Inactive Plugins ({inactivePlugins.length})</h3>
|
||||||
|
<div className={viewMode === 'grid' ? 'grid grid-cols-3 gap-4' : 'space-y-2'}>
|
||||||
|
{inactivePlugins.map((plugin) => (
|
||||||
|
<PluginCard
|
||||||
|
key={plugin.id}
|
||||||
|
plugin={plugin}
|
||||||
|
onActivate={() => activatePlugin(plugin.id)}
|
||||||
|
onDeactivate={() => deactivatePlugin(plugin.id)}
|
||||||
|
onClick={() => setSelectedPlugin(plugin)}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredPlugins.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Puzzle className="w-16 h-16 text-text-muted mx-auto mb-4" />
|
||||||
|
<p className="text-white font-medium">No plugins found</p>
|
||||||
|
<p className="text-text-muted text-sm mt-1">Try adjusting your search</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginCardProps {
|
||||||
|
plugin: Plugin
|
||||||
|
onActivate: () => void
|
||||||
|
onDeactivate: () => void
|
||||||
|
onClick: () => void
|
||||||
|
viewMode: 'grid' | 'list'
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginCard({ plugin, onActivate, onDeactivate, onClick, viewMode }: PluginCardProps) {
|
||||||
|
if (viewMode === 'list') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-surface rounded-lg border border-border hover:border-primary/30 transition-colors">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Puzzle className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-semibold text-white truncate">{plugin.name}</h4>
|
||||||
|
<p className="text-sm text-text-muted truncate">{plugin.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-text-muted">v{plugin.version}</span>
|
||||||
|
|
||||||
|
{plugin.active ? (
|
||||||
|
<button
|
||||||
|
onClick={onDeactivate}
|
||||||
|
className="px-3 py-1.5 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors"
|
||||||
|
>
|
||||||
|
Deactivate
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onActivate}
|
||||||
|
className="px-3 py-1.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-colors"
|
||||||
|
>
|
||||||
|
Activate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface rounded-xl p-5 border border-border hover:border-primary/30 transition-colors group">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<Puzzle className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plugin.active ? (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 bg-accent/10 rounded-full">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-accent" />
|
||||||
|
<span className="text-xs font-medium text-accent">Active</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 bg-surface-light rounded-full">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-text-muted" />
|
||||||
|
<span className="text-xs font-medium text-text-muted">Inactive</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="font-semibold text-white mb-1">{plugin.name}</h4>
|
||||||
|
<p className="text-sm text-text-muted mb-4 line-clamp-2">{plugin.description}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-text-muted mb-4">
|
||||||
|
<span>{plugin.author}</span>
|
||||||
|
<span>v{plugin.version}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{plugin.active ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={onDeactivate}
|
||||||
|
className="flex-1 px-3 py-2 bg-surface-light hover:bg-surface-light/80 text-text rounded-lg text-sm font-medium transition-colors border border-border"
|
||||||
|
>
|
||||||
|
Deactivate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onActivate}
|
||||||
|
className="w-full px-3 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Activate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { invoke } from '@tauri-apps/api/tauri'
|
||||||
|
import {
|
||||||
|
Settings2,
|
||||||
|
Keyboard,
|
||||||
|
Monitor,
|
||||||
|
Puzzle,
|
||||||
|
Save,
|
||||||
|
RotateCcw
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAppStore } from '../store/appStore'
|
||||||
|
|
||||||
|
interface SettingSection {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon: React.ElementType
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const { settings, updateSetting, loadSettings } = useAppStore()
|
||||||
|
const [activeSection, setActiveSection] = useState('general')
|
||||||
|
const [localSettings, setLocalSettings] = useState(settings)
|
||||||
|
const [hasChanges, setHasChanges] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSettings(settings)
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
const sections: SettingSection[] = [
|
||||||
|
{
|
||||||
|
id: 'general',
|
||||||
|
title: 'General',
|
||||||
|
icon: Settings2,
|
||||||
|
description: 'Application behavior and appearance'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hotkeys',
|
||||||
|
title: 'Hotkeys',
|
||||||
|
icon: Keyboard,
|
||||||
|
description: 'Keyboard shortcuts configuration'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'overlay',
|
||||||
|
title: 'Overlay',
|
||||||
|
icon: Monitor,
|
||||||
|
description: 'In-game overlay settings'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plugins',
|
||||||
|
title: 'Plugins',
|
||||||
|
icon: Puzzle,
|
||||||
|
description: 'Plugin system configuration'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleChange = (key: string, value: unknown) => {
|
||||||
|
const newSettings = { ...localSettings }
|
||||||
|
const keys = key.split('.')
|
||||||
|
let current: Record<string, unknown> = newSettings
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
if (!current[keys[i]]) current[keys[i]] = {}
|
||||||
|
current = current[keys[i]] as Record<string, unknown>
|
||||||
|
}
|
||||||
|
current[keys[keys.length - 1]] = value
|
||||||
|
|
||||||
|
setLocalSettings(newSettings)
|
||||||
|
setHasChanges(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
for (const [key, value] of Object.entries(localSettings)) {
|
||||||
|
await updateSetting(key, value)
|
||||||
|
}
|
||||||
|
setHasChanges(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setLocalSettings(settings)
|
||||||
|
setHasChanges(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">Settings</h2>
|
||||||
|
<p className="text-text-muted mt-1">Configure EU-Utility V3</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-text-muted hover:text-text transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-64 space-y-2">
|
||||||
|
{sections.map((section) => {
|
||||||
|
const Icon = section.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-left ${
|
||||||
|
activeSection === section.id
|
||||||
|
? 'bg-primary/10 text-primary border border-primary/20'
|
||||||
|
: 'text-text-muted hover:text-text hover:bg-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{section.title}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 bg-surface rounded-xl p-6 border border-border">
|
||||||
|
{activeSection === 'general' && (
|
||||||
|
<GeneralSettings
|
||||||
|
settings={localSettings}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeSection === 'hotkeys' && (
|
||||||
|
<HotkeySettings
|
||||||
|
settings={localSettings}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeSection === 'overlay' && (
|
||||||
|
<OverlaySettings
|
||||||
|
settings={localSettings}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeSection === 'plugins' && (
|
||||||
|
<PluginSettings
|
||||||
|
settings={localSettings}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneralSettings({ settings, onChange }: { settings: any, onChange: (k: string, v: unknown) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white">General Settings</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-surface-light rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">Start on Boot</p>
|
||||||
|
<p className="text-sm text-text-muted">Launch EU-Utility when system starts</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings?.general?.start_on_boot || false}
|
||||||
|
onChange={(e) => onChange('general.start_on_boot', e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-surface peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-surface-light rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">Minimize to Tray</p>
|
||||||
|
<p className="text-sm text-text-muted">Keep running in system tray when closed</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings?.general?.minimize_to_tray !== false}
|
||||||
|
onChange={(e) => onChange('general.minimize_to_tray', e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-surface peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-light rounded-lg">
|
||||||
|
<label className="block text-sm font-medium text-white mb-2">Theme</label>
|
||||||
|
<select
|
||||||
|
value={settings?.general?.theme || 'dark'}
|
||||||
|
onChange={(e) => onChange('general.theme', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-white focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="darker">Darker</option>
|
||||||
|
<option value="midnight">Midnight</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HotkeySettings({ settings, onChange }: { settings: any, onChange: (k: string, v: unknown) => void }) {
|
||||||
|
const hotkeys = [
|
||||||
|
{ key: 'overlay_toggle', label: 'Toggle Overlay', default: 'Ctrl+Shift+U' },
|
||||||
|
{ key: 'overlay_hide', label: 'Hide Overlay', default: 'Ctrl+Shift+H' },
|
||||||
|
{ key: 'search_universal', label: 'Universal Search', default: 'Ctrl+Shift+F' },
|
||||||
|
{ key: 'search_nexus', label: 'Nexus Search', default: 'Ctrl+Shift+N' },
|
||||||
|
{ key: 'calculator', label: 'Calculator', default: 'Ctrl+Shift+C' },
|
||||||
|
{ key: 'tracker_loot', label: 'Loot Tracker', default: 'Ctrl+Shift+L' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Hotkey Configuration</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{hotkeys.map((hotkey) => (
|
||||||
|
<div key={hotkey.key} className="flex items-center justify-between p-4 bg-surface-light rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">{hotkey.label}</p>
|
||||||
|
<p className="text-sm text-text-muted">Default: {hotkey.default}</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings?.hotkeys?.[hotkey.key] || hotkey.default}
|
||||||
|
onChange={(e) => onChange(`hotkeys.${hotkey.key}`, e.target.value)}
|
||||||
|
className="w-40 px-3 py-2 bg-surface border border-border rounded-lg text-center text-white font-mono text-sm focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverlaySettings({ settings, onChange }: { settings: any, onChange: (k: string, v: unknown) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Overlay Settings</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-surface-light rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-white font-medium">Opacity</label>
|
||||||
|
<span className="text-text-muted">{Math.round((settings?.overlay?.opacity || 0.9) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.1"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={settings?.overlay?.opacity || 0.9}
|
||||||
|
onChange={(e) => onChange('overlay.opacity', parseFloat(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-surface-light rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">Click Through</p>
|
||||||
|
<p className="text-sm text-text-muted">Allow clicks to pass through overlay</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings?.overlay?.click_through !== false}
|
||||||
|
onChange={(e) => onChange('overlay.click_through', e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-surface peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-light rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-white font-medium">Auto-hide Delay</label>
|
||||||
|
<span className="text-text-muted">{(settings?.overlay?.auto_hide_delay || 3000) / 1000}s</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1000"
|
||||||
|
max="10000"
|
||||||
|
step="500"
|
||||||
|
value={settings?.overlay?.auto_hide_delay || 3000}
|
||||||
|
onChange={(e) => onChange('overlay.auto_hide_delay', parseInt(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginSettings({ settings, onChange }: { settings: any, onChange: (k: string, v: unknown) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Plugin Configuration</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-surface-light rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">Auto-activate Plugins</p>
|
||||||
|
<p className="text-sm text-text-muted">Automatically activate plugins on startup</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings?.plugins?.auto_activate?.length > 0}
|
||||||
|
className="sr-only peer"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-surface peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-surface-light rounded-lg">
|
||||||
|
<label className="block text-sm font-medium text-white mb-2">Log Reader Path</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings?.log_reader?.log_path || ''}
|
||||||
|
onChange={(e) => onChange('log_reader.log_path', e.target.value)}
|
||||||
|
placeholder="Auto-detect"
|
||||||
|
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-white focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">Path to Entropia Universe chat log file</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { invoke } from '@tauri-apps/api/tauri'
|
||||||
|
import { listen } from '@tauri-apps/api/event'
|
||||||
|
|
||||||
|
export interface Plugin {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
author: string
|
||||||
|
description: string
|
||||||
|
active: boolean
|
||||||
|
has_overlay: boolean
|
||||||
|
hotkey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hotkey {
|
||||||
|
action: string
|
||||||
|
shortcut: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
plugins: Plugin[]
|
||||||
|
hotkeys: Hotkey[]
|
||||||
|
settings: Record<string, unknown>
|
||||||
|
isLoading: boolean
|
||||||
|
activeTab: string
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
initialize: () => Promise<void>
|
||||||
|
loadPlugins: () => Promise<void>
|
||||||
|
activatePlugin: (id: string) => Promise<void>
|
||||||
|
deactivatePlugin: (id: string) => Promise<void>
|
||||||
|
loadSettings: () => Promise<void>
|
||||||
|
updateSetting: (key: string, value: unknown) => Promise<void>
|
||||||
|
setActiveTab: (tab: string) => void
|
||||||
|
toggleOverlay: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set, get) => ({
|
||||||
|
plugins: [],
|
||||||
|
hotkeys: [],
|
||||||
|
settings: {},
|
||||||
|
isLoading: true,
|
||||||
|
activeTab: 'dashboard',
|
||||||
|
|
||||||
|
initialize: async () => {
|
||||||
|
await get().loadPlugins()
|
||||||
|
await get().loadSettings()
|
||||||
|
set({ isLoading: false })
|
||||||
|
|
||||||
|
// Listen for events
|
||||||
|
listen('plugin.activated', () => {
|
||||||
|
get().loadPlugins()
|
||||||
|
})
|
||||||
|
|
||||||
|
listen('plugin.deactivated', () => {
|
||||||
|
get().loadPlugins()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPlugins: async () => {
|
||||||
|
try {
|
||||||
|
const plugins = await invoke<Plugin[]>('get_plugins')
|
||||||
|
set({ plugins })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load plugins:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
activatePlugin: async (id: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('activate_plugin', { id })
|
||||||
|
await get().loadPlugins()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to activate plugin:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivatePlugin: async (id: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('deactivate_plugin', { id })
|
||||||
|
await get().loadPlugins()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to deactivate plugin:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSettings: async () => {
|
||||||
|
try {
|
||||||
|
const settings = await invoke<Record<string, unknown>>('get_settings')
|
||||||
|
const hotkeys = await invoke<Hotkey[]>('get_hotkeys')
|
||||||
|
set({ settings, hotkeys })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load settings:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSetting: async (key: string, value: unknown) => {
|
||||||
|
try {
|
||||||
|
await invoke('set_settings', { key, value })
|
||||||
|
await get().loadSettings()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update setting:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveTab: (tab: string) => {
|
||||||
|
set({ activeTab: tab })
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleOverlay: async () => {
|
||||||
|
try {
|
||||||
|
await invoke('toggle_overlay')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle overlay:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
.App {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading states */
|
||||||
|
.loading-shimmer {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
#1e1e2e 0%,
|
||||||
|
#2d2d3d 50%,
|
||||||
|
#1e1e2e 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.fade-enter {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-exit {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-exit-active {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay specific */
|
||||||
|
.overlay-draggable {
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-resizable {
|
||||||
|
resize: both;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hotkey display */
|
||||||
|
.hotkey-badge {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border: 1px solid #3d3d3d;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plugin status indicators */
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.active {
|
||||||
|
background: #10b981;
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.inactive {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.error {
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
background-color: #0a0a0f;
|
||||||
|
color: #e2e8f0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #13131a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #3d3d3d;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4d4d4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus */
|
||||||
|
*:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range input styling */
|
||||||
|
input[type="range"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: #1e1e2e;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox toggle */
|
||||||
|
input[type="checkbox"] {
|
||||||
|
accent-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-slow {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line clamp */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-primary {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop-blur-xs {
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plugin card hover */
|
||||||
|
.plugin-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 40px -10px rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button states */
|
||||||
|
button:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input focus states */
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: '#0a0a0f',
|
||||||
|
surface: '#13131a',
|
||||||
|
'surface-light': '#1e1e2e',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#6366f1',
|
||||||
|
hover: '#4f46e5',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: '#8b5cf6',
|
||||||
|
hover: '#7c3aed',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: '#10b981',
|
||||||
|
hover: '#059669',
|
||||||
|
},
|
||||||
|
warning: '#f59e0b',
|
||||||
|
danger: '#ef4444',
|
||||||
|
text: {
|
||||||
|
DEFAULT: '#e2e8f0',
|
||||||
|
muted: '#94a3b8',
|
||||||
|
dark: '#64748b',
|
||||||
|
},
|
||||||
|
border: 'rgba(99, 102, 241, 0.2)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.2s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backdropBlur: {
|
||||||
|
xs: '2px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig(async () => ({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
watch: {
|
||||||
|
ignored: ['**/src-tauri/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
envPrefix: ['VITE_', 'TAURI_'],
|
||||||
|
build: {
|
||||||
|
target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13',
|
||||||
|
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||||
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
|
},
|
||||||
|
}))
|
||||||
Loading…
Reference in New Issue