Tons of Stuff
This commit is contained in:
88
electron-app/src/main/database.ts
Normal file
88
electron-app/src/main/database.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export interface PriceAlert {
|
||||
id: string;
|
||||
auecAmount: number;
|
||||
maxPrice: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function initDatabase() {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dbPath = path.join(userDataPath, 'alerts.db');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Create alerts table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id TEXT PRIMARY KEY,
|
||||
auecAmount REAL NOT NULL,
|
||||
maxPrice REAL NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database initialized at:', dbPath);
|
||||
}
|
||||
|
||||
export function getAllAlerts(): PriceAlert[] {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM alerts');
|
||||
const rows = stmt.all() as any[];
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
auecAmount: row.auecAmount,
|
||||
maxPrice: row.maxPrice,
|
||||
enabled: row.enabled === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export function addAlert(alert: PriceAlert): void {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO alerts (id, auecAmount, maxPrice, enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(alert.id, alert.auecAmount, alert.maxPrice, alert.enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
export function updateAlert(alert: PriceAlert): void {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE alerts
|
||||
SET auecAmount = ?, maxPrice = ?, enabled = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(alert.auecAmount, alert.maxPrice, alert.enabled ? 1 : 0, alert.id);
|
||||
}
|
||||
|
||||
export function deleteAlert(id: string): void {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
|
||||
const stmt = db.prepare('DELETE FROM alerts WHERE id = ?');
|
||||
stmt.run(id);
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { setupIpcHandlers, cleanupIpcHandlers } from './ipc-handlers';
|
||||
import { initDatabase, closeDatabase } from './database';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1000,
|
||||
minHeight: 700,
|
||||
frame: false,
|
||||
backgroundColor: '#0a0e27',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
@@ -34,12 +40,53 @@ function createWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
function createTray(): void {
|
||||
// Create a simple icon for the tray (16x16 cyan square)
|
||||
const icon = nativeImage.createFromDataURL(
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABNSURBVDiNY/z//z8DJYCJgUIwasCoAaMGjBowaMIAhv///zNQCpiYKASjBowaMGrAqAGDJgxGDRg1YNSAUQNGDRg0YTBqwKgBowYMmjAAALmyAwVYbMsAAAAASUVORK5CYII='
|
||||
);
|
||||
|
||||
tray = new Tray(icon);
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show rmtPocketWatcher',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
tray.setToolTip('rmtPocketWatcher');
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
// Double-click to show window
|
||||
tray.on('double-click', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
app.whenReady().then(() => {
|
||||
initDatabase();
|
||||
createTray();
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', (e: Event) => {
|
||||
// Prevent app from quitting when window is closed (allow tray to keep it running)
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -50,4 +97,5 @@ app.on('activate', () => {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
cleanupIpcHandlers();
|
||||
closeDatabase();
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { WebSocketClient } from './websocket-client';
|
||||
import { PriceIndex } from '../shared/types';
|
||||
import * as db from './database';
|
||||
|
||||
let wsClient: WebSocketClient | null = null;
|
||||
|
||||
export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
const wsUrl = process.env.WS_URL || 'ws://localhost:3000/ws';
|
||||
|
||||
// Initialize WebSocket client
|
||||
wsClient = new WebSocketClient(wsUrl);
|
||||
// Initialize WebSocket client only once
|
||||
if (!wsClient) {
|
||||
wsClient = new WebSocketClient(wsUrl);
|
||||
}
|
||||
|
||||
// Forward WebSocket events to renderer
|
||||
wsClient.on('connected', () => {
|
||||
@@ -35,6 +38,22 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
mainWindow.webContents.send('ws:maxReconnectAttemptsReached');
|
||||
});
|
||||
|
||||
// Remove existing handlers
|
||||
try { ipcMain.removeHandler('ws:connect'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('ws:disconnect'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('ws:getStatus'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('ws:send'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('api:getLatestPrices'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:minimize'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:maximize'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:close'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:isMaximized'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:hideToTray'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('alerts:getAll'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('alerts:add'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('alerts:update'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('alerts:delete'); } catch (e) { /* ignore */ }
|
||||
|
||||
// IPC handlers for renderer requests
|
||||
ipcMain.handle('ws:connect', async () => {
|
||||
wsClient?.connect();
|
||||
@@ -58,16 +77,111 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Fetch initial data from API
|
||||
ipcMain.handle('api:getLatestPrices', async () => {
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3000';
|
||||
const response = await fetch(`${apiUrl}/api/prices/latest`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest prices:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Window control handlers
|
||||
ipcMain.handle('window:minimize', () => {
|
||||
mainWindow.minimize();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('window:close', () => {
|
||||
mainWindow.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:isMaximized', () => {
|
||||
return mainWindow.isMaximized();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:hideToTray', () => {
|
||||
mainWindow.hide();
|
||||
});
|
||||
|
||||
// Alert management handlers
|
||||
ipcMain.handle('alerts:getAll', async () => {
|
||||
try {
|
||||
return db.getAllAlerts();
|
||||
} catch (error) {
|
||||
console.error('Failed to get alerts:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('alerts:add', async (_event, alert: db.PriceAlert) => {
|
||||
try {
|
||||
db.addAlert(alert);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to add alert:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('alerts:update', async (_event, alert: db.PriceAlert) => {
|
||||
try {
|
||||
db.updateAlert(alert);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to update alert:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('alerts:delete', async (_event, id: string) => {
|
||||
try {
|
||||
db.deleteAlert(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete alert:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-connect on startup
|
||||
wsClient.connect();
|
||||
}
|
||||
|
||||
export function cleanupIpcHandlers(): void {
|
||||
wsClient?.disconnect();
|
||||
wsClient = null;
|
||||
if (wsClient) {
|
||||
wsClient.disconnect();
|
||||
wsClient = null;
|
||||
}
|
||||
|
||||
ipcMain.removeHandler('ws:connect');
|
||||
ipcMain.removeHandler('ws:disconnect');
|
||||
ipcMain.removeHandler('ws:getStatus');
|
||||
ipcMain.removeHandler('ws:send');
|
||||
// Safely remove handlers (won't throw if they don't exist)
|
||||
try {
|
||||
ipcMain.removeHandler('ws:connect');
|
||||
ipcMain.removeHandler('ws:disconnect');
|
||||
ipcMain.removeHandler('ws:getStatus');
|
||||
ipcMain.removeHandler('ws:send');
|
||||
ipcMain.removeHandler('api:getLatestPrices');
|
||||
ipcMain.removeHandler('window:minimize');
|
||||
ipcMain.removeHandler('window:maximize');
|
||||
ipcMain.removeHandler('window:close');
|
||||
ipcMain.removeHandler('window:isMaximized');
|
||||
ipcMain.removeHandler('window:hideToTray');
|
||||
ipcMain.removeHandler('alerts:getAll');
|
||||
ipcMain.removeHandler('alerts:add');
|
||||
ipcMain.removeHandler('alerts:update');
|
||||
ipcMain.removeHandler('alerts:delete');
|
||||
} catch (error) {
|
||||
// Handlers may not exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { TitleBar } from './components/TitleBar';
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: '6 Hours', value: '6h' },
|
||||
{ label: '24 Hours', value: '24h' },
|
||||
{ label: '3 Days', value: '3d' },
|
||||
{ label: '7 Days', value: '7d' },
|
||||
{ label: '1 Month', value: '1mo' },
|
||||
{ label: '6H', value: '6h' },
|
||||
{ label: '24H', value: '24h' },
|
||||
{ label: '3D', value: '3d' },
|
||||
{ label: '7D', value: '7d' },
|
||||
{ label: '1M', value: '1mo' },
|
||||
{ label: 'YTD', value: 'ytd' },
|
||||
];
|
||||
|
||||
@@ -16,19 +17,146 @@ const COLORS = [
|
||||
'#50e3c2', '#ff6b9d', '#4a90e2', '#7ed321', '#d0021b', '#f8e71c'
|
||||
];
|
||||
|
||||
interface ZoomState {
|
||||
xStart: number;
|
||||
xEnd: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
}
|
||||
|
||||
interface PriceAlert {
|
||||
id: string;
|
||||
auecAmount: number;
|
||||
maxPrice: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface AlertNotification {
|
||||
id: string;
|
||||
message: string;
|
||||
sellerName: string;
|
||||
platform: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { status, latestPrice, historyData, requestHistory } = useWebSocket();
|
||||
const { status, latestPrice, historyData, requestHistory, fetchInitialData } = useWebSocket();
|
||||
const [selectedRange, setSelectedRange] = useState('7d');
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [zoomState, setZoomState] = useState<ZoomState | null>(null);
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Price Alert State
|
||||
const [alerts, setAlerts] = useState<PriceAlert[]>([]);
|
||||
const [newAlertAuec, setNewAlertAuec] = useState('');
|
||||
const [newAlertPrice, setNewAlertPrice] = useState('');
|
||||
const [alertNotification, setAlertNotification] = useState<AlertNotification | null>(null);
|
||||
const alertAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// Load alerts from database on mount
|
||||
useEffect(() => {
|
||||
if (status.connected) {
|
||||
requestHistory(selectedRange);
|
||||
}
|
||||
}, [status.connected, selectedRange, requestHistory]);
|
||||
const loadAlerts = async () => {
|
||||
try {
|
||||
const savedAlerts = await window.electron.ipcRenderer.invoke('alerts:getAll');
|
||||
setAlerts(savedAlerts);
|
||||
} catch (error) {
|
||||
console.error('Failed to load alerts:', error);
|
||||
}
|
||||
};
|
||||
loadAlerts();
|
||||
}, []);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
// Fetch initial data on mount
|
||||
useEffect(() => {
|
||||
fetchInitialData();
|
||||
}, [fetchInitialData]);
|
||||
|
||||
// Request historical data when range changes
|
||||
useEffect(() => {
|
||||
requestHistory(selectedRange);
|
||||
}, [selectedRange, requestHistory]);
|
||||
|
||||
// Check for price alerts
|
||||
useEffect(() => {
|
||||
if (!latestPrice || alerts.length === 0) return;
|
||||
|
||||
alerts.forEach(alert => {
|
||||
if (!alert.enabled) return;
|
||||
|
||||
// Check if any seller meets the alert criteria
|
||||
const matchingSeller = latestPrice.allPrices.find(price => {
|
||||
const totalPrice = (alert.auecAmount / 1000000) * price.pricePerMillion;
|
||||
return totalPrice <= alert.maxPrice;
|
||||
});
|
||||
|
||||
if (matchingSeller) {
|
||||
const totalPrice = (alert.auecAmount / 1000000) * matchingSeller.pricePerMillion;
|
||||
|
||||
// Show notification
|
||||
const notification = {
|
||||
id: Date.now().toString(),
|
||||
message: `${alert.auecAmount.toLocaleString()} AUEC available for $${totalPrice.toFixed(2)}`,
|
||||
sellerName: matchingSeller.sellerName,
|
||||
platform: matchingSeller.platform,
|
||||
price: totalPrice,
|
||||
};
|
||||
|
||||
setAlertNotification(notification);
|
||||
|
||||
// Play alert sound
|
||||
if (alertAudioRef.current) {
|
||||
alertAudioRef.current.play().catch(e => console.error('Audio play failed:', e));
|
||||
}
|
||||
|
||||
// OS notification
|
||||
if (window.Notification && Notification.permission === 'granted') {
|
||||
new Notification('Price Alert - rmtPocketWatcher', {
|
||||
body: `${notification.message}\nSeller: ${matchingSeller.sellerName} (${matchingSeller.platform})`,
|
||||
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="75" font-size="75">💰</text></svg>'
|
||||
});
|
||||
}
|
||||
|
||||
// Disable alert after triggering
|
||||
const disabledAlert = { ...alert, enabled: false };
|
||||
window.electron.ipcRenderer.invoke('alerts:update', disabledAlert).catch(err =>
|
||||
console.error('Failed to update alert in DB:', err)
|
||||
);
|
||||
setAlerts(prev => prev.map(a =>
|
||||
a.id === alert.id ? disabledAlert : a
|
||||
));
|
||||
}
|
||||
});
|
||||
}, [latestPrice, alerts]);
|
||||
|
||||
// Request notification permission on mount
|
||||
useEffect(() => {
|
||||
if (window.Notification && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Helper function to format dates elegantly
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
// If within last 24 hours, show time
|
||||
if (diffHours < 24) {
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
// If within last 7 days, show day and time
|
||||
else if (diffDays < 7) {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
// Otherwise show date
|
||||
else {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
const fullChartData = useMemo(() => {
|
||||
if (!historyData?.prices) return [];
|
||||
|
||||
// Group by timestamp and seller
|
||||
@@ -39,7 +167,11 @@ export function App() {
|
||||
const key = time.toString();
|
||||
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, { timestamp: time, time: new Date(price.timestamp).toLocaleString() });
|
||||
grouped.set(key, {
|
||||
timestamp: time,
|
||||
time: formatDate(time),
|
||||
fullTime: new Date(price.timestamp).toLocaleString()
|
||||
});
|
||||
}
|
||||
|
||||
const entry = grouped.get(key);
|
||||
@@ -50,15 +182,41 @@ export function App() {
|
||||
return Array.from(grouped.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [historyData]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!fullChartData.length) return [];
|
||||
if (!zoomState) return fullChartData;
|
||||
|
||||
// Find data points within the zoom range, plus one point on each side for continuity
|
||||
const filtered = fullChartData.filter(d =>
|
||||
d.timestamp >= zoomState.xStart && d.timestamp <= zoomState.xEnd
|
||||
);
|
||||
|
||||
// If we have filtered data, return it
|
||||
if (filtered.length > 0) return filtered;
|
||||
|
||||
// If no exact matches, find the closest points to show something
|
||||
// This prevents empty chart when zoomed between data points
|
||||
const closestBefore = fullChartData.filter(d => d.timestamp < zoomState.xStart).slice(-2);
|
||||
const closestAfter = fullChartData.filter(d => d.timestamp > zoomState.xEnd).slice(0, 2);
|
||||
|
||||
return [...closestBefore, ...closestAfter];
|
||||
}, [fullChartData, zoomState]);
|
||||
|
||||
const sellers = useMemo(() => {
|
||||
if (!historyData?.prices) return [];
|
||||
const uniqueSellers = new Set(historyData.prices.map(p => p.sellerName));
|
||||
return Array.from(uniqueSellers);
|
||||
}, [historyData]);
|
||||
|
||||
const { minY, maxY } = useMemo(() => {
|
||||
const yAxisDomain = useMemo(() => {
|
||||
// Use zoom state if available
|
||||
if (zoomState) {
|
||||
return [zoomState.yMin, zoomState.yMax];
|
||||
}
|
||||
|
||||
// Default calculation
|
||||
if (!historyData?.prices || historyData.prices.length === 0) {
|
||||
return { minY: 0, maxY: 1 };
|
||||
return [0, 1];
|
||||
}
|
||||
|
||||
// Get all prices and sort them
|
||||
@@ -74,66 +232,225 @@ export function App() {
|
||||
// Base max Y on 90th percentile with padding
|
||||
const baseMaxY = Math.max(percentile90 * 1.1, minPrice * 2);
|
||||
|
||||
// Apply zoom level (higher zoom = smaller range)
|
||||
const zoomedMaxY = baseMaxY / zoomLevel;
|
||||
|
||||
console.log('Y-axis domain:', { minY: 0, maxY: zoomedMaxY, zoom: zoomLevel, base: baseMaxY });
|
||||
|
||||
return { minY: 0, maxY: zoomedMaxY };
|
||||
}, [historyData, zoomLevel]);
|
||||
return [0, baseMaxY];
|
||||
}, [historyData, zoomState]);
|
||||
|
||||
useEffect(() => {
|
||||
const chartContainer = chartContainerRef.current;
|
||||
if (!chartContainer) {
|
||||
console.log('Chart container ref not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Setting up wheel event listener on chart container');
|
||||
if (!chartContainer || !fullChartData.length) return;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
console.log('Wheel event:', e.deltaY);
|
||||
const isZoomIn = e.deltaY < 0;
|
||||
const zoomFactor = isZoomIn ? 0.8 : 1.25; // Zoom in = smaller range, zoom out = larger range
|
||||
|
||||
// Zoom in when scrolling up (negative deltaY), zoom out when scrolling down
|
||||
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
// Get mouse position relative to chart
|
||||
const rect = chartContainer.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
|
||||
setZoomLevel(prev => {
|
||||
const newZoom = prev * zoomDelta;
|
||||
const clampedZoom = Math.max(0.1, Math.min(10, newZoom));
|
||||
console.log('Zoom level:', prev, '->', clampedZoom);
|
||||
return clampedZoom;
|
||||
// Calculate mouse X position as percentage of chart area
|
||||
const xPercent = mouseX / rect.width;
|
||||
|
||||
// Get current ranges
|
||||
const currentXStart = zoomState?.xStart ?? fullChartData[0].timestamp;
|
||||
const currentXEnd = zoomState?.xEnd ?? fullChartData[fullChartData.length - 1].timestamp;
|
||||
const currentYMax = zoomState?.yMax ?? yAxisDomain[1];
|
||||
|
||||
// Calculate current X range
|
||||
const xRange = currentXEnd - currentXStart;
|
||||
|
||||
// Calculate new X range centered on mouse position
|
||||
let newXRange = xRange * zoomFactor;
|
||||
|
||||
// Prevent zooming in too much - ensure minimum time range
|
||||
// Calculate minimum based on data density
|
||||
const fullDataRange = fullChartData[fullChartData.length - 1].timestamp - fullChartData[0].timestamp;
|
||||
const minTimeRange = fullDataRange / 1000; // Allow up to 1000x zoom
|
||||
|
||||
if (isZoomIn && newXRange < minTimeRange) {
|
||||
newXRange = minTimeRange;
|
||||
}
|
||||
|
||||
// Calculate mouse position in X data coordinates
|
||||
const mouseXData = currentXStart + xRange * xPercent;
|
||||
|
||||
// Calculate new X bounds centered on mouse position
|
||||
let newXStart = mouseXData - newXRange * xPercent;
|
||||
let newXEnd = mouseXData + newXRange * (1 - xPercent);
|
||||
|
||||
// Constrain X to data bounds
|
||||
const dataXStart = fullChartData[0].timestamp;
|
||||
const dataXEnd = fullChartData[fullChartData.length - 1].timestamp;
|
||||
|
||||
if (newXStart < dataXStart) {
|
||||
newXEnd += dataXStart - newXStart;
|
||||
newXStart = dataXStart;
|
||||
}
|
||||
if (newXEnd > dataXEnd) {
|
||||
newXStart -= newXEnd - dataXEnd;
|
||||
newXEnd = dataXEnd;
|
||||
}
|
||||
newXStart = Math.max(dataXStart, newXStart);
|
||||
newXEnd = Math.min(dataXEnd, newXEnd);
|
||||
|
||||
// Y-axis: always zoom from 0, just adjust the max
|
||||
const newYMax = currentYMax * zoomFactor;
|
||||
|
||||
setZoomState({
|
||||
xStart: newXStart,
|
||||
xEnd: newXEnd,
|
||||
yMin: 0,
|
||||
yMax: newYMax,
|
||||
});
|
||||
};
|
||||
|
||||
chartContainer.addEventListener('wheel', handleWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
console.log('Removing wheel event listener');
|
||||
chartContainer.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, []);
|
||||
}, [fullChartData, zoomState, yAxisDomain]);
|
||||
|
||||
const handleRangeChange = (range: string) => {
|
||||
setSelectedRange(range);
|
||||
setZoomState(null);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoomLevel(1);
|
||||
setZoomState(null);
|
||||
};
|
||||
|
||||
const addAlert = async () => {
|
||||
const auec = parseFloat(newAlertAuec);
|
||||
const price = parseFloat(newAlertPrice);
|
||||
|
||||
if (isNaN(auec) || isNaN(price) || auec <= 0 || price <= 0) {
|
||||
alert('Please enter valid amounts');
|
||||
return;
|
||||
}
|
||||
|
||||
const newAlert: PriceAlert = {
|
||||
id: Date.now().toString(),
|
||||
auecAmount: auec,
|
||||
maxPrice: price,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('alerts:add', newAlert);
|
||||
setAlerts(prev => [...prev, newAlert]);
|
||||
setNewAlertAuec('');
|
||||
setNewAlertPrice('');
|
||||
} catch (error) {
|
||||
console.error('Failed to add alert:', error);
|
||||
alert('Failed to add alert');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAlert = async (id: string) => {
|
||||
const alert = alerts.find(a => a.id === id);
|
||||
if (!alert) return;
|
||||
|
||||
const updatedAlert = { ...alert, enabled: !alert.enabled };
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('alerts:update', updatedAlert);
|
||||
setAlerts(prev => prev.map(a =>
|
||||
a.id === id ? updatedAlert : a
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle alert:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAlert = async (id: string) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('alerts:delete', id);
|
||||
setAlerts(prev => prev.filter(a => a.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete alert:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissNotification = () => {
|
||||
setAlertNotification(null);
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (!fullChartData.length) return;
|
||||
|
||||
const currentXStart = zoomState?.xStart ?? fullChartData[0].timestamp;
|
||||
const currentXEnd = zoomState?.xEnd ?? fullChartData[fullChartData.length - 1].timestamp;
|
||||
const currentYMax = zoomState?.yMax ?? yAxisDomain[1];
|
||||
|
||||
const xRange = currentXEnd - currentXStart;
|
||||
const newXRange = xRange * 0.8;
|
||||
|
||||
const xCenter = (currentXStart + currentXEnd) / 2;
|
||||
|
||||
// Y-axis: zoom from 0
|
||||
const newYMax = currentYMax * 0.8;
|
||||
|
||||
setZoomState({
|
||||
xStart: xCenter - newXRange / 2,
|
||||
xEnd: xCenter + newXRange / 2,
|
||||
yMin: 0,
|
||||
yMax: newYMax,
|
||||
});
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (!fullChartData.length) return;
|
||||
|
||||
const currentXStart = zoomState?.xStart ?? fullChartData[0].timestamp;
|
||||
const currentXEnd = zoomState?.xEnd ?? fullChartData[fullChartData.length - 1].timestamp;
|
||||
const currentYMax = zoomState?.yMax ?? yAxisDomain[1];
|
||||
|
||||
const xRange = currentXEnd - currentXStart;
|
||||
const newXRange = xRange * 1.25;
|
||||
|
||||
const xCenter = (currentXStart + currentXEnd) / 2;
|
||||
|
||||
const dataXStart = fullChartData[0].timestamp;
|
||||
const dataXEnd = fullChartData[fullChartData.length - 1].timestamp;
|
||||
|
||||
const newXStart = Math.max(dataXStart, xCenter - newXRange / 2);
|
||||
const newXEnd = Math.min(dataXEnd, xCenter + newXRange / 2);
|
||||
|
||||
// Y-axis: zoom from 0
|
||||
const newYMax = currentYMax * 1.25;
|
||||
|
||||
setZoomState({
|
||||
xStart: newXStart,
|
||||
xEnd: newXEnd,
|
||||
yMin: 0,
|
||||
yMax: newYMax,
|
||||
});
|
||||
};
|
||||
|
||||
const getZoomLevel = () => {
|
||||
if (!zoomState || !fullChartData.length) return 1;
|
||||
|
||||
const fullXRange = fullChartData[fullChartData.length - 1].timestamp - fullChartData[0].timestamp;
|
||||
const currentXRange = zoomState.xEnd - zoomState.xStart;
|
||||
|
||||
return fullXRange / currentXRange;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
fontFamily: 'system-ui',
|
||||
backgroundColor: '#0a0e27',
|
||||
color: '#fff',
|
||||
minHeight: '100%'
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<h1 style={{ color: '#50e3c2', marginBottom: '10px' }}>rmtPocketWatcher</h1>
|
||||
<p style={{ color: '#888', fontSize: '14px', marginBottom: '20px' }}>Lambda Banking Conglomerate</p>
|
||||
<TitleBar />
|
||||
|
||||
<div style={{ padding: '20px', flex: 1 }}>
|
||||
|
||||
<div style={{ display: 'flex', gap: '20px', marginBottom: '20px' }}>
|
||||
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
|
||||
@@ -163,21 +480,141 @@ export function App() {
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h2 style={{ margin: '0 0 15px 0' }}>Price Alerts</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="AUEC Amount"
|
||||
value={newAlertAuec}
|
||||
onChange={(e) => setNewAlertAuec(e.target.value)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
flex: '1',
|
||||
minWidth: '150px',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max USD Price"
|
||||
value={newAlertPrice}
|
||||
onChange={(e) => setNewAlertPrice(e.target.value)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
flex: '1',
|
||||
minWidth: '150px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={addAlert}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#50e3c2',
|
||||
color: '#0a0e27',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Add Alert
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{alerts.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{alerts.map(alert => (
|
||||
<div
|
||||
key={alert.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
borderRadius: '4px',
|
||||
border: `2px solid ${alert.enabled ? '#50e3c2' : '#888'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 'bold' }}>
|
||||
{alert.auecAmount.toLocaleString()} AUEC for ${alert.maxPrice.toFixed(2)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||
${(alert.maxPrice / (alert.auecAmount / 1000000)).toFixed(4)} per 1M AUEC
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => toggleAlert(alert.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: alert.enabled ? '#50e3c2' : '#888',
|
||||
color: '#0a0e27',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{alert.enabled ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteAlert(alert.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#ff6b9d',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#888' }}>
|
||||
No alerts set. Add an alert to get notified when prices meet your criteria.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<h2 style={{ margin: 0 }}>Price History</h2>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{TIME_RANGES.map(range => (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => handleRangeChange(range.value)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
padding: '6px 14px',
|
||||
backgroundColor: selectedRange === range.value ? '#50e3c2' : '#2a2f4a',
|
||||
color: selectedRange === range.value ? '#0a0e27' : '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: selectedRange === range.value ? 'bold' : 'normal',
|
||||
fontSize: '13px',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{range.label}
|
||||
@@ -186,28 +623,71 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{historyData && historyData.prices && historyData.prices.length > 0 ? (
|
||||
chartData.length > 0 ? (
|
||||
<div>
|
||||
<div style={{ marginBottom: '10px', color: '#888', fontSize: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Scroll on chart to zoom. Extreme outliers may be clipped for better visibility.</span>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
Reset Zoom (×{zoomLevel.toFixed(1)})
|
||||
</button>
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '15px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
title="Zoom Out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
title="Zoom In"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Reset (×{getZoomLevel().toFixed(1)})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ color: '#888', fontSize: '11px', fontStyle: 'italic' }}>
|
||||
Scroll on chart to zoom into area • Outliers may be clipped
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
style={{
|
||||
@@ -215,48 +695,64 @@ export function App() {
|
||||
height: '500px',
|
||||
minHeight: '400px',
|
||||
cursor: 'ns-resize',
|
||||
border: '1px solid #2a2f4a'
|
||||
border: '1px solid #2a2f4a',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#0f1329',
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} key={`chart-${zoomLevel}`}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2a2f4a" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 10 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 12 }}
|
||||
label={{ value: 'USD per 1M AUEC', angle: -90, position: 'insideLeft', fill: '#888' }}
|
||||
domain={[0, yAxisDomain[1]]}
|
||||
allowDataOverflow={true}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1a1f3a', border: '1px solid #2a2f4a', borderRadius: '4px' }}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ color: '#fff', maxHeight: '100px', overflowY: 'auto' }}
|
||||
iconType="line"
|
||||
/>
|
||||
{sellers.map((seller, index) => (
|
||||
<Line
|
||||
key={seller}
|
||||
type="monotone"
|
||||
dataKey={seller}
|
||||
stroke={COLORS[index % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2a2f4a" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 10 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<YAxis
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 12 }}
|
||||
label={{ value: 'USD per 1M AUEC', angle: -90, position: 'insideLeft', fill: '#888' }}
|
||||
domain={yAxisDomain}
|
||||
allowDataOverflow={true}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1a1f3a', border: '1px solid #2a2f4a', borderRadius: '4px' }}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
labelFormatter={(label, payload) => {
|
||||
if (payload && payload.length > 0 && payload[0].payload.fullTime) {
|
||||
return payload[0].payload.fullTime;
|
||||
}
|
||||
return label;
|
||||
}}
|
||||
formatter={(value: any, name: string) => [`$${Number(value).toFixed(4)}`, name]}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
color: '#fff',
|
||||
maxHeight: '100px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1,
|
||||
paddingRight: '5px'
|
||||
}}
|
||||
iconType="line"
|
||||
/>
|
||||
{sellers.map((seller) => (
|
||||
<Line
|
||||
key={seller}
|
||||
type="monotone"
|
||||
dataKey={seller}
|
||||
stroke={COLORS[sellers.indexOf(seller) % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -273,8 +769,14 @@ export function App() {
|
||||
|
||||
{latestPrice && (
|
||||
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px' }}>
|
||||
<h2>Current Listings ({latestPrice.allPrices.length})</h2>
|
||||
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: '400px' }}>
|
||||
<h2 style={{ margin: '0 0 15px 0' }}>Current Listings ({latestPrice.allPrices.length})</h2>
|
||||
<div style={{
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
maxHeight: '400px',
|
||||
border: '1px solid #2a2f4a',
|
||||
borderRadius: '4px',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, backgroundColor: '#1a1f3a', zIndex: 1 }}>
|
||||
<tr style={{ borderBottom: '2px solid #2a2f4a' }}>
|
||||
@@ -284,7 +786,7 @@ export function App() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{latestPrice.allPrices.map((price, index) => (
|
||||
{latestPrice.allPrices.map((price) => (
|
||||
<tr key={price.id} style={{ borderBottom: '1px solid #2a2f4a' }}>
|
||||
<td style={{ padding: '12px' }}>{price.platform}</td>
|
||||
<td style={{ padding: '12px' }}>{price.sellerName}</td>
|
||||
@@ -298,6 +800,57 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert Notification Popup */}
|
||||
{alertNotification && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
right: '20px',
|
||||
backgroundColor: '#1a1f3a',
|
||||
border: '2px solid #50e3c2',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 4px 20px rgba(80, 227, 194, 0.4)',
|
||||
zIndex: 9999,
|
||||
animation: 'slideIn 0.3s ease-out',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '10px' }}>
|
||||
<h3 style={{ margin: 0, color: '#50e3c2', fontSize: '18px' }}>🎯 Price Alert!</h3>
|
||||
<button
|
||||
onClick={dismissNotification}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
lineHeight: '1',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||
{alertNotification.message}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#888' }}>
|
||||
Seller: <span style={{ color: '#fff' }}>{alertNotification.sellerName}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#888' }}>
|
||||
Platform: <span style={{ color: '#fff' }}>{alertNotification.platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert Audio */}
|
||||
<audio
|
||||
ref={alertAudioRef}
|
||||
src="data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIGGS57OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6OyrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWQ="
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
173
electron-app/src/renderer/components/TitleBar.tsx
Normal file
173
electron-app/src/renderer/components/TitleBar.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function TitleBar() {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let retries = 0;
|
||||
const maxRetries = 5;
|
||||
|
||||
const checkMaximized = async () => {
|
||||
try {
|
||||
const maximized = await window.electron.ipcRenderer.invoke('window:isMaximized');
|
||||
setIsMaximized(maximized);
|
||||
} catch (error) {
|
||||
if (retries < maxRetries) {
|
||||
retries++;
|
||||
setTimeout(checkMaximized, 200 * retries);
|
||||
} else {
|
||||
console.error('Failed to check maximized state after retries:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to ensure handlers are registered
|
||||
const timer = setTimeout(checkMaximized, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.electron.ipcRenderer.invoke('window:minimize');
|
||||
};
|
||||
|
||||
const handleMaximize = async () => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('window:maximize');
|
||||
const maximized = await window.electron.ipcRenderer.invoke('window:isMaximized');
|
||||
setIsMaximized(maximized);
|
||||
} catch (error) {
|
||||
console.error('Failed to maximize window:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
window.electron.ipcRenderer.invoke('window:close');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '40px',
|
||||
backgroundColor: '#0f1329',
|
||||
borderBottom: '1px solid #50e3c2',
|
||||
WebkitAppRegion: 'drag',
|
||||
userSelect: 'none',
|
||||
padding: '0 15px',
|
||||
} as any}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#50e3c2',
|
||||
boxShadow: '0 0 8px rgba(80, 227, 194, 0.6)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '14px', fontWeight: 'bold', color: '#50e3c2' }}>
|
||||
rmtPocketWatcher
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#888', marginLeft: '5px' }}>
|
||||
Lambda Banking Conglomerate
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
} as any}
|
||||
>
|
||||
<button
|
||||
onClick={() => window.electron.ipcRenderer.invoke('window:hideToTray')}
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#2a2f4a')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
title="Hide to Tray"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#2a2f4a')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
title="Minimize"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#2a2f4a')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{isMaximized ? '❐' : '□'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#ff6b9d')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,17 @@ export function useWebSocket() {
|
||||
await sendMessage({ type: 'get_history', data: { range } });
|
||||
}, [sendMessage]);
|
||||
|
||||
const fetchInitialData = useCallback(async () => {
|
||||
try {
|
||||
const response = await window.electron.ipcRenderer.invoke('api:getLatestPrices');
|
||||
if (response.success && response.data) {
|
||||
setLatestPrice(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch initial data:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status,
|
||||
latestPrice,
|
||||
@@ -92,5 +103,6 @@ export function useWebSocket() {
|
||||
disconnect,
|
||||
sendMessage,
|
||||
requestHistory,
|
||||
fetchInitialData,
|
||||
};
|
||||
}
|
||||
|
||||
43
electron-app/src/renderer/index.css
Normal file
43
electron-app/src/renderer/index.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/* Custom Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0a0e27;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #50e3c2;
|
||||
border-radius: 5px;
|
||||
border: 2px solid #0a0e27;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3dc9aa;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: #0a0e27;
|
||||
}
|
||||
|
||||
/* Apply to all scrollable elements */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #50e3c2 #0a0e27;
|
||||
}
|
||||
|
||||
|
||||
/* Alert notification animation */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Root element not found');
|
||||
|
||||
Reference in New Issue
Block a user