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:
Aether 2026-02-23 17:46:32 +00:00
parent 4942c66365
commit 97afdcc3b9
No known key found for this signature in database
GPG Key ID: 95AFEE837E39AFD2
31 changed files with 4050 additions and 0 deletions

53
.gitignore vendored Normal file
View File

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

301
docs/API_DOCUMENTATION.md Normal file
View File

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

323
docs/PLUGIN_GUIDE.md Normal file
View File

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

13
index.html Normal file
View File

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

34
package.json Normal file
View File

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

60
src-tauri/Cargo.toml Normal file
View File

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

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

381
src-tauri/src/api.rs Normal file
View File

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

109
src-tauri/src/events.rs Normal file
View File

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

233
src-tauri/src/hotkeys.rs Normal file
View File

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

148
src-tauri/src/main.rs Normal file
View File

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

91
src-tauri/src/nexus.rs Normal file
View File

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

194
src-tauri/src/plugins.rs Normal file
View File

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

158
src-tauri/src/settings.rs Normal file
View File

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

74
src-tauri/src/window.rs Normal file
View File

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

115
src-tauri/tauri.conf.json Normal file
View File

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

32
src/App.tsx Normal file
View File

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

107
src/components/Layout.tsx Normal file
View File

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

13
src/main.tsx Normal file
View File

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

160
src/pages/Dashboard.tsx Normal file
View File

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

229
src/pages/Nexus.tsx Normal file
View File

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

148
src/pages/Overlay.tsx Normal file
View File

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

216
src/pages/Plugins.tsx Normal file
View File

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

349
src/pages/Settings.tsx Normal file
View File

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

119
src/store/appStore.ts Normal file
View File

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

88
src/styles/App.css Normal file
View File

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

179
src/styles/index.css Normal file
View File

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

59
tailwind.config.js Normal file
View File

@ -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: [],
}

25
tsconfig.json Normal file
View File

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

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

26
vite.config.ts Normal file
View File

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