Tons of Stuff
Some checks failed
Deploy Backend to Docker / deploy (push) Failing after 6m13s
Deploy Backend to Docker / deploy-portainer (push) Has been skipped

This commit is contained in:
2025-12-03 20:19:40 -05:00
parent 0b86c88eb4
commit b7c2e0fc24
19 changed files with 1783 additions and 134 deletions

View File

@@ -0,0 +1,49 @@
# Gitea Actions - Release Workflow
This workflow automatically builds and releases rmtPocketWatcher for Windows and Linux when you push a version tag.
## How to Trigger a Release
1. Update version in `electron-app/package.json`:
```bash
cd electron-app
npm version patch # or minor, or major
```
2. Push the tag to Gitea:
```bash
git push origin main
git push origin --tags
```
3. The workflow will automatically:
- Build Windows installer (.exe)
- Build Linux AppImage and .deb package
- Create a GitHub/Gitea release
- Upload all binaries to the release
## Requirements
- Gitea Actions must be enabled on your repository
- Runners must be configured for `windows-latest` and `ubuntu-latest`
- Repository must have write permissions for releases
## Manual Build
To build locally without releasing:
```bash
cd electron-app
npm run electron:build -- --win # Windows
npm run electron:build -- --linux # Linux
```
Outputs will be in `electron-app/release/`
## Troubleshooting
If the workflow fails:
- Check that Node.js 20 is available on runners
- Verify all dependencies install correctly
- Check Gitea Actions logs for specific errors
- Ensure GITHUB_TOKEN has proper permissions

View File

@@ -0,0 +1,86 @@
name: Deploy Backend to Docker
on:
push:
branches:
- main
paths:
- 'backend/**'
- 'docker-compose.yml'
- '.gitea/workflows/deploy.yml'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build backend image
working-directory: backend
run: docker build -t rmtpocketwatcher-backend:latest .
- name: Save image to tar
run: docker save rmtpocketwatcher-backend:latest -o backend-image.tar
- name: Deploy via SSH to Docker host
env:
DOCKER_HOST: ${{ secrets.DOCKER_HOST }}
DOCKER_SSH_KEY: ${{ secrets.DOCKER_SSH_KEY }}
DOCKER_USER: ${{ secrets.DOCKER_USER }}
run: |
# Setup SSH
mkdir -p ~/.ssh
echo "$DOCKER_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
# Copy image to remote host
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
backend-image.tar ${DOCKER_USER}@${DOCKER_HOST}:/tmp/
# Copy docker-compose to remote host
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
docker-compose.yml ${DOCKER_USER}@${DOCKER_HOST}:/opt/rmtpocketwatcher/
# Load image and restart services
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
${DOCKER_USER}@${DOCKER_HOST} << 'EOF'
cd /opt/rmtpocketwatcher
docker load -i /tmp/backend-image.tar
docker-compose down backend
docker-compose up -d backend
rm /tmp/backend-image.tar
EOF
deploy-portainer:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Deploy via Portainer Webhook
env:
PORTAINER_WEBHOOK_URL: ${{ secrets.PORTAINER_WEBHOOK_URL }}
run: |
curl -X POST "$PORTAINER_WEBHOOK_URL"
- name: Deploy via Portainer API
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }}
run: |
# Pull latest from git and redeploy stack
curl -X PUT \
"${PORTAINER_URL}/api/stacks/${PORTAINER_STACK_ID}/git/redeploy" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"pullImage": true,
"prune": true
}'

View File

@@ -0,0 +1,96 @@
name: Build and Release
on:
push:
tags:
- 'v*'
jobs:
build-windows:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install electron-app dependencies
working-directory: electron-app
run: npm ci
- name: Build and package for Windows
working-directory: electron-app
run: npm run electron:build -- --win
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: windows-build
path: electron-app/release/*.exe
retention-days: 7
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install electron-app dependencies
working-directory: electron-app
run: npm ci
- name: Build and package for Linux
working-directory: electron-app
run: npm run electron:build -- --linux
- name: Upload Linux AppImage
uses: actions/upload-artifact@v4
with:
name: linux-appimage
path: electron-app/release/*.AppImage
retention-days: 7
- name: Upload Linux deb
uses: actions/upload-artifact@v4
with:
name: linux-deb
path: electron-app/release/*.deb
retention-days: 7
create-release:
needs: [build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: ./artifacts
- name: Display structure of downloaded files
run: ls -R ./artifacts
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/windows-build/*.exe
artifacts/linux-appimage/*.AppImage
artifacts/linux-deb/*.deb
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,23 +4,44 @@ import { PriceRepository } from '../../database/repository';
const repository = new PriceRepository(); const repository = new PriceRepository();
export const priceRoutes: FastifyPluginAsync = async (server) => { export const priceRoutes: FastifyPluginAsync = async (server) => {
// GET /api/prices/latest - Get all current listings // GET /api/prices/latest - Get all current listings with lowest price
server.get('/latest', async (request, reply) => { server.get('/latest', async (request, reply) => {
try { try {
const prices = await repository.getLatestPrices(); const prices = await repository.getLatestPrices();
if (prices.length === 0) {
return {
success: true,
data: {
timestamp: new Date(),
lowestPrice: 0,
platform: '',
sellerName: '',
allPrices: []
}
};
}
const lowestPrice = prices.reduce((min: any, p: any) =>
Number(p.usdPerMillion) < Number(min.usdPerMillion) ? p : min
);
return { return {
success: true, success: true,
data: prices.map((p: any) => ({ data: {
id: p.id, timestamp: new Date(),
timestamp: p.timestamp, lowestPrice: Number(lowestPrice.usdPerMillion),
vendor: p.vendor, platform: lowestPrice.vendor,
seller: p.sellerName, sellerName: lowestPrice.sellerName,
usdPrice: p.usdPrice.toString(), allPrices: prices.map((p: any) => ({
auecAmount: p.auecAmount.toString(), id: p.id,
usdPerMillion: p.usdPerMillion.toString(), platform: p.vendor,
deliveryTime: p.deliveryTime, sellerName: p.sellerName,
url: p.url, pricePerMillion: Number(p.usdPerMillion),
})), timestamp: p.timestamp,
url: p.url
}))
}
}; };
} catch (error) { } catch (error) {
reply.code(500); reply.code(500);

147
deploy/README.md Normal file
View File

@@ -0,0 +1,147 @@
# Deployment Guide
Multiple deployment methods for rmtPocketWatcher backend.
## Method 1: Portainer with Git Repository
**Best for:** Teams using Portainer for container management
### Setup in Portainer UI:
1. Go to Stacks → Add Stack → Git Repository
2. Configure:
- **Name**: rmtpocketwatcher
- **Repository URL**: Your Git repo URL
- **Reference**: refs/heads/main
- **Compose path**: deploy/portainer-stack.yml
3. Add environment variables:
- `POSTGRES_PASSWORD`: your_secure_password
- `IMAGE_TAG`: latest
4. Deploy
### Update via Webhook:
1. In Portainer, enable webhook for the stack
2. Add webhook URL to Gitea secrets: `PORTAINER_WEBHOOK_URL`
3. Push to main branch triggers auto-deploy
### Update via API Script:
```bash
export PORTAINER_URL="http://your-portainer:9000"
export PORTAINER_API_KEY="your_api_key"
export POSTGRES_PASSWORD="your_password"
export GIT_REPO_URL="https://your-git-repo.git"
chmod +x deploy/portainer-deploy.sh
./deploy/portainer-deploy.sh
```
## Method 2: Direct Docker via SSH
**Best for:** Simple deployments to a single Docker host
```bash
export DOCKER_HOST="your-server.com"
export DOCKER_USER="deploy"
export GIT_REPO="https://your-git-repo.git"
chmod +x deploy/docker-deploy.sh
./deploy/docker-deploy.sh
```
For local deployment:
```bash
export DOCKER_HOST="localhost"
export GIT_REPO="https://your-git-repo.git"
./deploy/docker-deploy.sh
```
## Method 3: Gitea Actions (Automated)
**Best for:** CI/CD pipeline automation
### Setup:
1. Add secrets to your Gitea repository:
- `DOCKER_HOST`: your-server.com
- `DOCKER_USER`: deploy
- `DOCKER_SSH_KEY`: Your SSH private key
2. Push to main branch or modify backend files
3. Workflow automatically builds and deploys
### Manual trigger:
- Go to Actions → Deploy Backend to Docker → Run workflow
## Method 4: Portainer API via Gitea Actions
**Best for:** Portainer users with CI/CD
### Setup:
1. Get Portainer API key:
- Portainer → User Settings → Access tokens → Add access token
2. Add secrets to Gitea:
- `PORTAINER_URL`: http://your-portainer:9000
- `PORTAINER_API_KEY`: your_api_key
- `PORTAINER_STACK_ID`: stack_id (from Portainer URL)
- `PORTAINER_WEBHOOK_URL`: webhook URL (optional)
3. Manually trigger workflow or use webhook
## Environment Variables
Required for all methods:
- `POSTGRES_PASSWORD`: Database password
Optional:
- `POSTGRES_USER`: Database user (default: rmtpw)
- `POSTGRES_DB`: Database name (default: rmtpocketwatcher)
- `BACKEND_PORT`: Backend port (default: 3000)
- `SCRAPE_INTERVAL`: Minutes between scrapes (default: 5)
- `SCRAPER_TIMEOUT`: Scraper timeout in ms (default: 30000)
- `SCRAPER_MAX_RETRIES`: Max retry attempts (default: 3)
## Monitoring
Check deployment status:
```bash
docker-compose ps
docker-compose logs -f backend
```
Via Portainer:
- Stacks → rmtpocketwatcher → Container logs
## Rollback
### Portainer:
1. Go to Stack → Editor
2. Change `IMAGE_TAG` to previous version
3. Update stack
### Direct Docker:
```bash
cd /opt/rmtpocketwatcher
git checkout <previous-commit>
docker-compose up -d --build backend
```
## Troubleshooting
**Stack won't start:**
- Check environment variables are set
- Verify database connection
- Check logs: `docker-compose logs backend`
**Portainer API fails:**
- Verify API key is valid
- Check endpoint ID matches your environment
- Ensure Portainer version supports Git deployment
**SSH deployment fails:**
- Verify SSH key has correct permissions (600)
- Check user has Docker permissions
- Ensure Git is installed on remote host

75
deploy/docker-deploy.sh Normal file
View File

@@ -0,0 +1,75 @@
#!/bin/bash
# Direct Docker Deployment Script
# This script deploys rmtPocketWatcher directly to a Docker host via SSH
set -e
# Configuration
DOCKER_HOST="${DOCKER_HOST:-localhost}"
DOCKER_USER="${DOCKER_USER:-root}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/rmtpocketwatcher}"
GIT_REPO="${GIT_REPO}"
GIT_BRANCH="${GIT_BRANCH:-main}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${GREEN}Deploying rmtPocketWatcher to Docker host...${NC}"
# Check if we're deploying locally or remotely
if [ "$DOCKER_HOST" = "localhost" ]; then
echo -e "${YELLOW}Deploying locally...${NC}"
# Pull latest code
if [ -d "$DEPLOY_PATH" ]; then
cd "$DEPLOY_PATH"
git pull origin "$GIT_BRANCH"
else
git clone -b "$GIT_BRANCH" "$GIT_REPO" "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
fi
# Build and deploy
docker-compose down
docker-compose build backend
docker-compose up -d
echo -e "${GREEN}Local deployment complete!${NC}"
else
echo -e "${YELLOW}Deploying to remote host: ${DOCKER_HOST}${NC}"
# Deploy via SSH
ssh ${DOCKER_USER}@${DOCKER_HOST} << EOF
set -e
# Pull or clone repository
if [ -d "${DEPLOY_PATH}" ]; then
cd "${DEPLOY_PATH}"
git pull origin ${GIT_BRANCH}
else
git clone -b ${GIT_BRANCH} ${GIT_REPO} ${DEPLOY_PATH}
cd "${DEPLOY_PATH}"
fi
# Build and deploy
docker-compose down
docker-compose build backend
docker-compose up -d
echo "Deployment complete!"
EOF
echo -e "${GREEN}Remote deployment complete!${NC}"
fi
# Show status
echo -e "${YELLOW}Checking service status...${NC}"
if [ "$DOCKER_HOST" = "localhost" ]; then
docker-compose ps
else
ssh ${DOCKER_USER}@${DOCKER_HOST} "cd ${DEPLOY_PATH} && docker-compose ps"
fi

View File

@@ -0,0 +1,76 @@
#!/bin/bash
# Portainer Deployment Script
# This script deploys or updates the rmtPocketWatcher stack via Portainer API
set -e
# Configuration
PORTAINER_URL="${PORTAINER_URL:-http://localhost:9000}"
PORTAINER_API_KEY="${PORTAINER_API_KEY}"
STACK_NAME="${STACK_NAME:-rmtpocketwatcher}"
ENDPOINT_ID="${ENDPOINT_ID:-1}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Check required variables
if [ -z "$PORTAINER_API_KEY" ]; then
echo -e "${RED}Error: PORTAINER_API_KEY is not set${NC}"
echo "Usage: PORTAINER_API_KEY=your_key ./portainer-deploy.sh"
exit 1
fi
echo -e "${GREEN}Deploying rmtPocketWatcher to Portainer...${NC}"
# Check if stack exists
STACK_ID=$(curl -s -X GET \
"${PORTAINER_URL}/api/stacks" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
| jq -r ".[] | select(.Name==\"${STACK_NAME}\") | .Id")
if [ -z "$STACK_ID" ] || [ "$STACK_ID" = "null" ]; then
echo -e "${YELLOW}Stack not found. Creating new stack...${NC}"
# Create new stack from Git repository
curl -X POST \
"${PORTAINER_URL}/api/stacks/create/standalone/repository" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
-H "Content-Type: application/json" \
-d @- << EOF
{
"name": "${STACK_NAME}",
"endpointId": ${ENDPOINT_ID},
"repositoryURL": "${GIT_REPO_URL}",
"repositoryReferenceName": "refs/heads/main",
"composeFile": "deploy/portainer-stack.yml",
"repositoryAuthentication": false,
"env": [
{"name": "POSTGRES_PASSWORD", "value": "${POSTGRES_PASSWORD}"},
{"name": "IMAGE_TAG", "value": "latest"}
]
}
EOF
echo -e "${GREEN}Stack created successfully!${NC}"
else
echo -e "${YELLOW}Stack found (ID: ${STACK_ID}). Updating...${NC}"
# Update existing stack via Git pull
curl -X PUT \
"${PORTAINER_URL}/api/stacks/${STACK_ID}/git/redeploy" \
-H "X-API-Key: ${PORTAINER_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"pullImage": true,
"prune": true
}'
echo -e "${GREEN}Stack updated successfully!${NC}"
fi
echo -e "${GREEN}Deployment complete!${NC}"
echo -e "Access your backend at: ${PORTAINER_URL%:*}:3000"

View File

@@ -0,0 +1,52 @@
version: '3.8'
# Portainer Stack Configuration
# This file is used when deploying via Portainer's Git repository feature
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-rmtpw}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-rmtpocketwatcher}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- rmtpw-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rmtpw} -d ${POSTGRES_DB:-rmtpocketwatcher}"]
interval: 10s
timeout: 5s
retries: 5
backend:
image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-lambdabanking/rmtpocketwatcher-backend}:${IMAGE_TAG:-latest}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-rmtpw}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-rmtpocketwatcher}?schema=public
PORT: ${BACKEND_PORT:-3000}
HOST: 0.0.0.0
NODE_ENV: production
SCRAPE_INTERVAL_MINUTES: ${SCRAPE_INTERVAL:-5}
SCRAPER_HEADLESS: "true"
SCRAPER_TIMEOUT: ${SCRAPER_TIMEOUT:-30000}
SCRAPER_MAX_RETRIES: ${SCRAPER_MAX_RETRIES:-3}
ports:
- "${BACKEND_PORT:-3000}:3000"
networks:
- rmtpw-network
labels:
- "com.centurylinklabs.watchtower.enable=true"
volumes:
postgres_data:
driver: local
networks:
rmtpw-network:
driver: bridge

View File

@@ -16,6 +16,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: #0a0e27;
} }
#root { #root {
@@ -23,6 +24,8 @@
height: 100%; height: 100%;
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden; overflow-x: hidden;
border: 2px solid #50e3c2;
box-shadow: 0 0 20px rgba(80, 227, 194, 0.3);
} }
/* Custom scrollbar */ /* Custom scrollbar */

View File

@@ -10,15 +10,20 @@
"build": "npm run build:main && npm run build:renderer", "build": "npm run build:main && npm run build:renderer",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron dist/main/index.js\"", "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron dist/main/index.js\"",
"electron:build": "npm run build && electron-builder", "electron:build": "npm run build && electron-builder",
"publish": "npm run build && electron-builder --publish always",
"rebuild": "electron-rebuild",
"postinstall": "electron-rebuild",
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.5.0",
"electron": "^30.0.0", "electron": "^30.0.0",
"electron-updater": "^6.1.0", "electron-updater": "^6.1.0",
"recharts": "^3.5.1", "recharts": "^3.5.1",
"ws": "^8.16.0" "ws": "^8.16.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"@types/react": "^18.3.27", "@types/react": "^18.3.27",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
@@ -26,6 +31,7 @@
"concurrently": "^8.2.0", "concurrently": "^8.2.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron-builder": "^24.9.0", "electron-builder": "^24.9.0",
"electron-rebuild": "^3.2.9",
"react": "^19.2.1", "react": "^19.2.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",
"typescript": "^5.3.0", "typescript": "^5.3.0",
@@ -42,6 +48,10 @@
"dist/**/*", "dist/**/*",
"package.json" "package.json"
], ],
"publish": {
"provider": "generic",
"url": "${env.RELEASE_URL}"
},
"win": { "win": {
"target": [ "target": [
"nsis" "nsis"
@@ -54,8 +64,10 @@
}, },
"linux": { "linux": {
"target": [ "target": [
"AppImage" "AppImage",
] "deb"
],
"category": "Finance"
} }
} }
} }

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

View File

@@ -1,13 +1,19 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
import * as path from 'path'; import * as path from 'path';
import { setupIpcHandlers, cleanupIpcHandlers } from './ipc-handlers'; import { setupIpcHandlers, cleanupIpcHandlers } from './ipc-handlers';
import { initDatabase, closeDatabase } from './database';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
function createWindow(): void { function createWindow(): void {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1400, width: 1400,
height: 900, height: 900,
minWidth: 1000,
minHeight: 700,
frame: false,
backgroundColor: '#0a0e27',
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false, 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='
);
app.on('window-all-closed', () => { tray = new Tray(icon);
if (process.platform !== 'darwin') {
app.quit(); 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.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', () => { app.on('activate', () => {
@@ -50,4 +97,5 @@ app.on('activate', () => {
app.on('before-quit', () => { app.on('before-quit', () => {
cleanupIpcHandlers(); cleanupIpcHandlers();
closeDatabase();
}); });

View File

@@ -1,14 +1,17 @@
import { ipcMain, BrowserWindow } from 'electron'; import { ipcMain, BrowserWindow } from 'electron';
import { WebSocketClient } from './websocket-client'; import { WebSocketClient } from './websocket-client';
import { PriceIndex } from '../shared/types'; import { PriceIndex } from '../shared/types';
import * as db from './database';
let wsClient: WebSocketClient | null = null; let wsClient: WebSocketClient | null = null;
export function setupIpcHandlers(mainWindow: BrowserWindow): void { export function setupIpcHandlers(mainWindow: BrowserWindow): void {
const wsUrl = process.env.WS_URL || 'ws://localhost:3000/ws'; const wsUrl = process.env.WS_URL || 'ws://localhost:3000/ws';
// Initialize WebSocket client // Initialize WebSocket client only once
wsClient = new WebSocketClient(wsUrl); if (!wsClient) {
wsClient = new WebSocketClient(wsUrl);
}
// Forward WebSocket events to renderer // Forward WebSocket events to renderer
wsClient.on('connected', () => { wsClient.on('connected', () => {
@@ -35,6 +38,22 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
mainWindow.webContents.send('ws:maxReconnectAttemptsReached'); 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 // IPC handlers for renderer requests
ipcMain.handle('ws:connect', async () => { ipcMain.handle('ws:connect', async () => {
wsClient?.connect(); wsClient?.connect();
@@ -58,16 +77,111 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
return { success: true }; 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 // Auto-connect on startup
wsClient.connect(); wsClient.connect();
} }
export function cleanupIpcHandlers(): void { export function cleanupIpcHandlers(): void {
wsClient?.disconnect(); if (wsClient) {
wsClient = null; wsClient.disconnect();
wsClient = null;
}
ipcMain.removeHandler('ws:connect'); // Safely remove handlers (won't throw if they don't exist)
ipcMain.removeHandler('ws:disconnect'); try {
ipcMain.removeHandler('ws:getStatus'); ipcMain.removeHandler('ws:connect');
ipcMain.removeHandler('ws:send'); 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
}
} }

View File

@@ -1,13 +1,14 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { useWebSocket } from './hooks/useWebSocket'; import { useWebSocket } from './hooks/useWebSocket';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { TitleBar } from './components/TitleBar';
const TIME_RANGES = [ const TIME_RANGES = [
{ label: '6 Hours', value: '6h' }, { label: '6H', value: '6h' },
{ label: '24 Hours', value: '24h' }, { label: '24H', value: '24h' },
{ label: '3 Days', value: '3d' }, { label: '3D', value: '3d' },
{ label: '7 Days', value: '7d' }, { label: '7D', value: '7d' },
{ label: '1 Month', value: '1mo' }, { label: '1M', value: '1mo' },
{ label: 'YTD', value: 'ytd' }, { label: 'YTD', value: 'ytd' },
]; ];
@@ -16,19 +17,146 @@ const COLORS = [
'#50e3c2', '#ff6b9d', '#4a90e2', '#7ed321', '#d0021b', '#f8e71c' '#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() { export function App() {
const { status, latestPrice, historyData, requestHistory } = useWebSocket(); const { status, latestPrice, historyData, requestHistory, fetchInitialData } = useWebSocket();
const [selectedRange, setSelectedRange] = useState('7d'); const [selectedRange, setSelectedRange] = useState('7d');
const [zoomLevel, setZoomLevel] = useState(1); const [zoomState, setZoomState] = useState<ZoomState | null>(null);
const chartContainerRef = useRef<HTMLDivElement>(null); const chartContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { // Price Alert State
if (status.connected) { const [alerts, setAlerts] = useState<PriceAlert[]>([]);
requestHistory(selectedRange); const [newAlertAuec, setNewAlertAuec] = useState('');
} const [newAlertPrice, setNewAlertPrice] = useState('');
}, [status.connected, selectedRange, requestHistory]); const [alertNotification, setAlertNotification] = useState<AlertNotification | null>(null);
const alertAudioRef = useRef<HTMLAudioElement | null>(null);
const chartData = useMemo(() => { // Load alerts from database on mount
useEffect(() => {
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();
}, []);
// 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 []; if (!historyData?.prices) return [];
// Group by timestamp and seller // Group by timestamp and seller
@@ -39,7 +167,11 @@ export function App() {
const key = time.toString(); const key = time.toString();
if (!grouped.has(key)) { 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); const entry = grouped.get(key);
@@ -50,15 +182,41 @@ export function App() {
return Array.from(grouped.values()).sort((a, b) => a.timestamp - b.timestamp); return Array.from(grouped.values()).sort((a, b) => a.timestamp - b.timestamp);
}, [historyData]); }, [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(() => { const sellers = useMemo(() => {
if (!historyData?.prices) return []; if (!historyData?.prices) return [];
const uniqueSellers = new Set(historyData.prices.map(p => p.sellerName)); const uniqueSellers = new Set(historyData.prices.map(p => p.sellerName));
return Array.from(uniqueSellers); return Array.from(uniqueSellers);
}, [historyData]); }, [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) { if (!historyData?.prices || historyData.prices.length === 0) {
return { minY: 0, maxY: 1 }; return [0, 1];
} }
// Get all prices and sort them // Get all prices and sort them
@@ -74,66 +232,225 @@ export function App() {
// Base max Y on 90th percentile with padding // Base max Y on 90th percentile with padding
const baseMaxY = Math.max(percentile90 * 1.1, minPrice * 2); const baseMaxY = Math.max(percentile90 * 1.1, minPrice * 2);
// Apply zoom level (higher zoom = smaller range) return [0, baseMaxY];
const zoomedMaxY = baseMaxY / zoomLevel; }, [historyData, zoomState]);
console.log('Y-axis domain:', { minY: 0, maxY: zoomedMaxY, zoom: zoomLevel, base: baseMaxY });
return { minY: 0, maxY: zoomedMaxY };
}, [historyData, zoomLevel]);
useEffect(() => { useEffect(() => {
const chartContainer = chartContainerRef.current; const chartContainer = chartContainerRef.current;
if (!chartContainer) { if (!chartContainer || !fullChartData.length) return;
console.log('Chart container ref not found');
return;
}
console.log('Setting up wheel event listener on chart container');
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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 // Get mouse position relative to chart
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1; const rect = chartContainer.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
setZoomLevel(prev => { // Calculate mouse X position as percentage of chart area
const newZoom = prev * zoomDelta; const xPercent = mouseX / rect.width;
const clampedZoom = Math.max(0.1, Math.min(10, newZoom));
console.log('Zoom level:', prev, '->', clampedZoom); // Get current ranges
return clampedZoom; 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 }); chartContainer.addEventListener('wheel', handleWheel, { passive: false });
return () => { return () => {
console.log('Removing wheel event listener');
chartContainer.removeEventListener('wheel', handleWheel); chartContainer.removeEventListener('wheel', handleWheel);
}; };
}, []); }, [fullChartData, zoomState, yAxisDomain]);
const handleRangeChange = (range: string) => { const handleRangeChange = (range: string) => {
setSelectedRange(range); setSelectedRange(range);
setZoomState(null);
}; };
const resetZoom = () => { 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 ( return (
<div style={{ <div style={{
padding: '20px',
fontFamily: 'system-ui', fontFamily: 'system-ui',
backgroundColor: '#0a0e27', backgroundColor: '#0a0e27',
color: '#fff', color: '#fff',
minHeight: '100%' minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
}}> }}>
<h1 style={{ color: '#50e3c2', marginBottom: '10px' }}>rmtPocketWatcher</h1> <TitleBar />
<p style={{ color: '#888', fontSize: '14px', marginBottom: '20px' }}>Lambda Banking Conglomerate</p>
<div style={{ padding: '20px', flex: 1 }}>
<div style={{ display: 'flex', gap: '20px', marginBottom: '20px' }}> <div style={{ display: 'flex', gap: '20px', marginBottom: '20px' }}>
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}> <div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
@@ -163,21 +480,141 @@ export function App() {
</div> </div>
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}> <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> <h2 style={{ margin: 0 }}>Price History</h2>
<div style={{ display: 'flex', gap: '10px' }}> <div style={{ display: 'flex', gap: '8px' }}>
{TIME_RANGES.map(range => ( {TIME_RANGES.map(range => (
<button <button
key={range.value} key={range.value}
onClick={() => handleRangeChange(range.value)} onClick={() => handleRangeChange(range.value)}
style={{ style={{
padding: '8px 16px', padding: '6px 14px',
backgroundColor: selectedRange === range.value ? '#50e3c2' : '#2a2f4a', backgroundColor: selectedRange === range.value ? '#50e3c2' : '#2a2f4a',
color: selectedRange === range.value ? '#0a0e27' : '#fff', color: selectedRange === range.value ? '#0a0e27' : '#fff',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
fontWeight: selectedRange === range.value ? 'bold' : 'normal', fontWeight: selectedRange === range.value ? 'bold' : 'normal',
fontSize: '13px',
transition: 'all 0.2s',
}} }}
> >
{range.label} {range.label}
@@ -186,28 +623,71 @@ export function App() {
</div> </div>
</div> </div>
{historyData && historyData.prices && historyData.prices.length > 0 ? ( {historyData && historyData.prices && historyData.prices.length > 0 ? (
chartData.length > 0 ? ( chartData.length > 0 ? (
<div> <div>
<div style={{ marginBottom: '10px', color: '#888', fontSize: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{
<span>Scroll on chart to zoom. Extreme outliers may be clipped for better visibility.</span> marginBottom: '12px',
<button display: 'flex',
onClick={resetZoom} justifyContent: 'space-between',
style={{ alignItems: 'center',
padding: '4px 12px', gap: '15px',
backgroundColor: '#2a2f4a', flexWrap: 'wrap'
color: '#fff', }}>
border: '1px solid #50e3c2', <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
borderRadius: '4px', <button
cursor: 'pointer', onClick={handleZoomOut}
fontSize: '11px' style={{
}} padding: '6px 12px',
> backgroundColor: '#2a2f4a',
Reset Zoom (×{zoomLevel.toFixed(1)}) color: '#fff',
</button> 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>
<div <div
ref={chartContainerRef} ref={chartContainerRef}
style={{ style={{
@@ -215,48 +695,64 @@ export function App() {
height: '500px', height: '500px',
minHeight: '400px', minHeight: '400px',
cursor: 'ns-resize', cursor: 'ns-resize',
border: '1px solid #2a2f4a' border: '1px solid #2a2f4a',
borderRadius: '4px',
backgroundColor: '#0f1329',
}} }}
> >
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} key={`chart-${zoomLevel}`}> <LineChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2f4a" /> <CartesianGrid strokeDasharray="3 3" stroke="#2a2f4a" />
<XAxis <XAxis
dataKey="time" dataKey="time"
stroke="#888" stroke="#888"
tick={{ fill: '#888', fontSize: 10 }} tick={{ fill: '#888', fontSize: 10 }}
angle={-45} angle={-45}
textAnchor="end" textAnchor="end"
height={80} 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
/> />
))} <YAxis
</LineChart> stroke="#888"
</ResponsiveContainer> 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>
</div> </div>
) : ( ) : (
@@ -273,8 +769,14 @@ export function App() {
{latestPrice && ( {latestPrice && (
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px' }}> <div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px' }}>
<h2>Current Listings ({latestPrice.allPrices.length})</h2> <h2 style={{ margin: '0 0 15px 0' }}>Current Listings ({latestPrice.allPrices.length})</h2>
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: '400px' }}> <div style={{
overflowX: 'auto',
overflowY: 'auto',
maxHeight: '400px',
border: '1px solid #2a2f4a',
borderRadius: '4px',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead style={{ position: 'sticky', top: 0, backgroundColor: '#1a1f3a', zIndex: 1 }}> <thead style={{ position: 'sticky', top: 0, backgroundColor: '#1a1f3a', zIndex: 1 }}>
<tr style={{ borderBottom: '2px solid #2a2f4a' }}> <tr style={{ borderBottom: '2px solid #2a2f4a' }}>
@@ -284,7 +786,7 @@ export function App() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{latestPrice.allPrices.map((price, index) => ( {latestPrice.allPrices.map((price) => (
<tr key={price.id} style={{ borderBottom: '1px solid #2a2f4a' }}> <tr key={price.id} style={{ borderBottom: '1px solid #2a2f4a' }}>
<td style={{ padding: '12px' }}>{price.platform}</td> <td style={{ padding: '12px' }}>{price.platform}</td>
<td style={{ padding: '12px' }}>{price.sellerName}</td> <td style={{ padding: '12px' }}>{price.sellerName}</td>
@@ -298,6 +800,57 @@ export function App() {
</div> </div>
</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> </div>
); );
} }

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

View File

@@ -84,6 +84,17 @@ export function useWebSocket() {
await sendMessage({ type: 'get_history', data: { range } }); await sendMessage({ type: 'get_history', data: { range } });
}, [sendMessage]); }, [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 { return {
status, status,
latestPrice, latestPrice,
@@ -92,5 +103,6 @@ export function useWebSocket() {
disconnect, disconnect,
sendMessage, sendMessage,
requestHistory, requestHistory,
fetchInitialData,
}; };
} }

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

View File

@@ -1,5 +1,6 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App } from './App'; import { App } from './App';
import './index.css';
const container = document.getElementById('root'); const container = document.getElementById('root');
if (!container) throw new Error('Root element not found'); if (!container) throw new Error('Root element not found');

View File

@@ -1,7 +1,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist/main", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src"
}, },
"include": ["src/main/**/*", "src/shared/**/*"] "include": ["src/main/**/*", "src/shared/**/*"]