Tons of Stuff
This commit is contained in:
49
.gitea/workflows/README.md
Normal file
49
.gitea/workflows/README.md
Normal 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
|
||||||
86
.gitea/workflows/deploy.yml
Normal file
86
.gitea/workflows/deploy.yml
Normal 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
|
||||||
|
}'
|
||||||
96
.gitea/workflows/release.yml
Normal file
96
.gitea/workflows/release.yml
Normal 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 }}
|
||||||
@@ -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
147
deploy/README.md
Normal 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
75
deploy/docker-deploy.sh
Normal 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
|
||||||
76
deploy/portainer-deploy.sh
Normal file
76
deploy/portainer-deploy.sh
Normal 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"
|
||||||
52
deploy/portainer-stack.yml
Normal file
52
deploy/portainer-stack.yml
Normal 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
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 * 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 } });
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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');
|
||||||
|
|||||||
@@ -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/**/*"]
|
||||||
|
|||||||
Reference in New Issue
Block a user