build 1
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/*.db
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
37
backend/Dockerfile
Normal file
37
backend/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3 make g++ \
|
||||||
|
--no-install-recommends \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json tsconfig.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build && npm prune --production
|
||||||
|
|
||||||
|
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# Install Chromium and runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
chromium \
|
||||||
|
fonts-freefont-ttf \
|
||||||
|
--no-install-recommends \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
24
backend/package.json
Normal file
24
backend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "ui-tracker-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^9.6.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"puppeteer-core": "^22.8.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.10",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/src/database.ts
Normal file
57
backend/src/database.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, '../../data/tracker.db');
|
||||||
|
|
||||||
|
let db: Database.Database;
|
||||||
|
|
||||||
|
export function getDb(): Database.Database {
|
||||||
|
if (!db) {
|
||||||
|
db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
initSchema(db);
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSchema(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS watched_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
check_interval INTEGER NOT NULL DEFAULT 60,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_status TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
alert_sent INTEGER NOT NULL DEFAULT 0,
|
||||||
|
check_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_checked_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('telegram_bot_token', '');
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('telegram_chat_id', '');
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StockStatus = 'in_stock' | 'sold_out' | 'unknown';
|
||||||
|
|
||||||
|
export interface WatchedItem {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
name: string | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
check_interval: number;
|
||||||
|
is_active: number;
|
||||||
|
last_status: StockStatus;
|
||||||
|
alert_sent: number;
|
||||||
|
check_count: number;
|
||||||
|
last_checked_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
27
backend/src/index.ts
Normal file
27
backend/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { getDb } from './database';
|
||||||
|
import { initScheduler } from './scheduler';
|
||||||
|
import itemsRouter from './routes/items';
|
||||||
|
import settingsRouter from './routes/settings';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.use('/api/items', itemsRouter);
|
||||||
|
app.use('/api/settings', settingsRouter);
|
||||||
|
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Boot sequence
|
||||||
|
getDb();
|
||||||
|
initScheduler();
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`[Server] UI Tracker backend running on port ${PORT}`);
|
||||||
|
});
|
||||||
122
backend/src/routes/items.ts
Normal file
122
backend/src/routes/items.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { getDb, WatchedItem } from '../database';
|
||||||
|
import { startItem, stopItem } from '../scheduler';
|
||||||
|
import { checkStockStatus } from '../scraper';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/items
|
||||||
|
router.get('/', (_req: Request, res: Response) => {
|
||||||
|
const items = getDb().prepare('SELECT * FROM watched_items ORDER BY created_at DESC').all();
|
||||||
|
res.json(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/items
|
||||||
|
router.post('/', async (req: Request, res: Response) => {
|
||||||
|
const { url, check_interval } = req.body as { url?: string; check_interval?: number | string };
|
||||||
|
|
||||||
|
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
|
||||||
|
res.status(400).json({ error: 'A valid URL is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = Math.max(30, parseInt(String(check_interval ?? 60), 10) || 60);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = db
|
||||||
|
.prepare('INSERT INTO watched_items (url, check_interval) VALUES (?, ?) RETURNING *')
|
||||||
|
.get(url.trim(), interval) as WatchedItem;
|
||||||
|
|
||||||
|
// Async initial scrape — populates name + thumbnail in background
|
||||||
|
checkStockStatus(url.trim())
|
||||||
|
.then(result => {
|
||||||
|
const updates: Record<string, string | number> = { last_status: result.status };
|
||||||
|
if (result.name) updates.name = result.name;
|
||||||
|
if (result.thumbnail) updates.thumbnail_url = result.thumbnail;
|
||||||
|
const set = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
|
||||||
|
db.prepare(`UPDATE watched_items SET ${set} WHERE id = @id`).run({ ...updates, id: item.id });
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[Items] Initial scrape failed:', err));
|
||||||
|
|
||||||
|
startItem(item);
|
||||||
|
res.status(201).json(item);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const sqliteErr = err as { code?: string };
|
||||||
|
if (sqliteErr.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
||||||
|
res.status(409).json({ error: 'This URL is already being tracked' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to add item' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/items/:id
|
||||||
|
router.put('/:id', (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const db = getDb();
|
||||||
|
const item = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id) as WatchedItem | undefined;
|
||||||
|
|
||||||
|
if (!item) { res.status(404).json({ error: 'Item not found' }); return; }
|
||||||
|
|
||||||
|
const { check_interval, is_active } = req.body as { check_interval?: number | string; is_active?: boolean | number };
|
||||||
|
|
||||||
|
const newInterval = check_interval !== undefined
|
||||||
|
? Math.max(30, parseInt(String(check_interval), 10) || item.check_interval)
|
||||||
|
: item.check_interval;
|
||||||
|
|
||||||
|
const newActive = is_active !== undefined ? (is_active ? 1 : 0) : item.is_active;
|
||||||
|
|
||||||
|
db.prepare('UPDATE watched_items SET check_interval = ?, is_active = ? WHERE id = ?')
|
||||||
|
.run(newInterval, newActive, id);
|
||||||
|
|
||||||
|
const updated = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id) as WatchedItem;
|
||||||
|
|
||||||
|
if (newActive && !item.is_active) {
|
||||||
|
startItem(updated);
|
||||||
|
} else if (!newActive && item.is_active) {
|
||||||
|
stopItem(id);
|
||||||
|
} else if (newActive && newInterval !== item.check_interval) {
|
||||||
|
startItem(updated); // restart with new interval
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/items/:id
|
||||||
|
router.delete('/:id', (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
stopItem(id);
|
||||||
|
getDb().prepare('DELETE FROM watched_items WHERE id = ?').run(id);
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/items/:id/pause
|
||||||
|
router.post('/:id/pause', (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const db = getDb();
|
||||||
|
stopItem(id);
|
||||||
|
db.prepare('UPDATE watched_items SET is_active = 0 WHERE id = ?').run(id);
|
||||||
|
res.json(db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/items/:id/resume
|
||||||
|
router.post('/:id/resume', (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('UPDATE watched_items SET is_active = 1 WHERE id = ?').run(id);
|
||||||
|
const item = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id) as WatchedItem;
|
||||||
|
startItem(item);
|
||||||
|
res.json(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/items/:id/reset — clears alert_sent so the next in-stock triggers a new alert
|
||||||
|
router.post('/:id/reset', (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('UPDATE watched_items SET alert_sent = 0 WHERE id = ?').run(id);
|
||||||
|
res.json(db.prepare('SELECT * FROM watched_items WHERE id = ?').get(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
36
backend/src/routes/settings.ts
Normal file
36
backend/src/routes/settings.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { getDb } from '../database';
|
||||||
|
import { testTelegramConnection } from '../telegram';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
function getAllSettings(): Record<string, string> {
|
||||||
|
const rows = getDb().prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
|
||||||
|
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/settings
|
||||||
|
router.get('/', (_req: Request, res: Response) => {
|
||||||
|
res.json(getAllSettings());
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/settings
|
||||||
|
router.put('/', (req: Request, res: Response) => {
|
||||||
|
const { telegram_bot_token, telegram_chat_id } = req.body as Record<string, string>;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
|
||||||
|
|
||||||
|
if (telegram_bot_token !== undefined) upsert.run('telegram_bot_token', telegram_bot_token);
|
||||||
|
if (telegram_chat_id !== undefined) upsert.run('telegram_chat_id', telegram_chat_id);
|
||||||
|
|
||||||
|
res.json(getAllSettings());
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/settings/test-telegram
|
||||||
|
router.post('/test-telegram', async (_req: Request, res: Response) => {
|
||||||
|
const result = await testTelegramConnection();
|
||||||
|
res.status(result.success ? 200 : 400).json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
85
backend/src/scheduler.ts
Normal file
85
backend/src/scheduler.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { getDb, WatchedItem } from './database';
|
||||||
|
import { checkStockStatus } from './scraper';
|
||||||
|
import { sendTelegramAlert } from './telegram';
|
||||||
|
|
||||||
|
const MIN_INTERVAL_SECONDS = 30;
|
||||||
|
const timers = new Map<number, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
async function runCheck(itemId: number): Promise<void> {
|
||||||
|
const db = getDb();
|
||||||
|
const item = db.prepare('SELECT * FROM watched_items WHERE id = ?').get(itemId) as WatchedItem | undefined;
|
||||||
|
|
||||||
|
if (!item || !item.is_active) return;
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Checking item ${itemId} — ${item.name || item.url}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await checkStockStatus(item.url);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
let alertSent = item.alert_sent;
|
||||||
|
|
||||||
|
// Fire alert only on transition to in_stock when no alert has been sent yet
|
||||||
|
if (result.status === 'in_stock' && !item.alert_sent) {
|
||||||
|
const displayName = result.name || item.name || item.url;
|
||||||
|
await sendTelegramAlert(
|
||||||
|
`🟢 <b>Back in Stock!</b>\n\n` +
|
||||||
|
`<b>${displayName}</b>\n\n` +
|
||||||
|
`<a href="${item.url}">Open in Store →</a>`
|
||||||
|
);
|
||||||
|
alertSent = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update payload
|
||||||
|
const updates: Record<string, string | number> = {
|
||||||
|
last_status: result.status,
|
||||||
|
alert_sent: alertSent,
|
||||||
|
check_count: item.check_count + 1,
|
||||||
|
last_checked_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Populate name and thumbnail on first successful scrape
|
||||||
|
if (result.name && !item.name) updates.name = result.name;
|
||||||
|
if (result.thumbnail && !item.thumbnail_url) updates.thumbnail_url = result.thumbnail;
|
||||||
|
|
||||||
|
const setClause = Object.keys(updates).map(k => `${k} = @${k}`).join(', ');
|
||||||
|
db.prepare(`UPDATE watched_items SET ${setClause} WHERE id = @id`).run({ ...updates, id: itemId });
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Item ${itemId} → ${result.status} (check #${item.check_count + 1})`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Scheduler] Error checking item ${itemId}:`, err);
|
||||||
|
// Still increment count so the UI shows activity
|
||||||
|
db.prepare('UPDATE watched_items SET check_count = check_count + 1, last_checked_at = ? WHERE id = ?')
|
||||||
|
.run(new Date().toISOString(), itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startItem(item: WatchedItem): void {
|
||||||
|
stopItem(item.id);
|
||||||
|
|
||||||
|
const intervalMs = Math.max(MIN_INTERVAL_SECONDS, item.check_interval) * 1000;
|
||||||
|
const timer = setInterval(() => runCheck(item.id), intervalMs);
|
||||||
|
timers.set(item.id, timer);
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Started item ${item.id} — interval ${intervalMs / 1000}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopItem(id: number): void {
|
||||||
|
const timer = timers.get(id);
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timers.delete(id);
|
||||||
|
console.log(`[Scheduler] Stopped item ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initScheduler(): void {
|
||||||
|
const db = getDb();
|
||||||
|
const activeItems = db.prepare('SELECT * FROM watched_items WHERE is_active = 1').all() as WatchedItem[];
|
||||||
|
|
||||||
|
for (const item of activeItems) {
|
||||||
|
startItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Initialized — ${activeItems.length} active item(s)`);
|
||||||
|
}
|
||||||
113
backend/src/scraper.ts
Normal file
113
backend/src/scraper.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import puppeteer from 'puppeteer-core';
|
||||||
|
import type { StockStatus } from './database';
|
||||||
|
|
||||||
|
export interface ScrapeResult {
|
||||||
|
status: StockStatus;
|
||||||
|
name?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit concurrent Puppeteer instances to avoid OOM on Unraid
|
||||||
|
class Semaphore {
|
||||||
|
private permits: number;
|
||||||
|
private queue: Array<() => void> = [];
|
||||||
|
|
||||||
|
constructor(permits: number) {
|
||||||
|
this.permits = permits;
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire(): Promise<void> {
|
||||||
|
if (this.permits > 0) {
|
||||||
|
this.permits--;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise(resolve => this.queue.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
release(): void {
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (next) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
this.permits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrapeSemaphore = new Semaphore(2);
|
||||||
|
|
||||||
|
export async function checkStockStatus(url: string): Promise<ScrapeResult> {
|
||||||
|
await scrapeSemaphore.acquire();
|
||||||
|
|
||||||
|
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium';
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath,
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-accelerated-2d-canvas',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-zygote',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
// Spoof navigator to reduce bot detection
|
||||||
|
await page.evaluateOnNewDocument(() => {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.setUserAgent(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
||||||
|
);
|
||||||
|
await page.setViewport({ width: 1280, height: 800 });
|
||||||
|
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||||
|
|
||||||
|
// Wait for React hydration
|
||||||
|
await new Promise(r => setTimeout(r, 2500));
|
||||||
|
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
let status: 'in_stock' | 'sold_out' | 'unknown' = 'unknown';
|
||||||
|
|
||||||
|
// Check all spans for the known button text
|
||||||
|
const spans = document.querySelectorAll('span');
|
||||||
|
for (const span of spans) {
|
||||||
|
const text = span.textContent?.trim();
|
||||||
|
if (text === 'Add to Cart') { status = 'in_stock'; break; }
|
||||||
|
if (text === 'Sold Out') { status = 'sold_out'; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product name: og:title is most reliable for single-product pages
|
||||||
|
let name: string | undefined;
|
||||||
|
const ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement | null;
|
||||||
|
if (ogTitle?.content) {
|
||||||
|
name = ogTitle.content.replace(' – Ubiquiti Store', '').replace(' | Ubiquiti Store', '').trim();
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
const h1 = document.querySelector('h1');
|
||||||
|
name = h1?.textContent?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail: og:image is the most reliable
|
||||||
|
let thumbnail: string | undefined;
|
||||||
|
const ogImage = document.querySelector('meta[property="og:image"]') as HTMLMetaElement | null;
|
||||||
|
if (ogImage?.content) {
|
||||||
|
thumbnail = ogImage.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status, name, thumbnail };
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as ScrapeResult;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
scrapeSemaphore.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
67
backend/src/telegram.ts
Normal file
67
backend/src/telegram.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { getDb } from './database';
|
||||||
|
|
||||||
|
function getCredentials(): { token: string; chatId: string } | null {
|
||||||
|
const db = getDb();
|
||||||
|
const token = (db.prepare('SELECT value FROM settings WHERE key = ?').get('telegram_bot_token') as { value: string } | undefined)?.value;
|
||||||
|
const chatId = (db.prepare('SELECT value FROM settings WHERE key = ?').get('telegram_chat_id') as { value: string } | undefined)?.value;
|
||||||
|
|
||||||
|
if (!token || !chatId) return null;
|
||||||
|
return { token, chatId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTelegramAlert(message: string): Promise<void> {
|
||||||
|
const creds = getCredentials();
|
||||||
|
|
||||||
|
if (!creds) {
|
||||||
|
console.warn('[Telegram] Bot token or chat ID not configured — skipping alert');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.telegram.org/bot${creds.token}/sendMessage`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: creds.chatId,
|
||||||
|
text: message,
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
console.error('[Telegram] Failed to send alert:', res.status, body);
|
||||||
|
} else {
|
||||||
|
console.log('[Telegram] Alert sent successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testTelegramConnection(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const creds = getCredentials();
|
||||||
|
|
||||||
|
if (!creds) {
|
||||||
|
return { success: false, error: 'Bot token or chat ID is not configured.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://api.telegram.org/bot${creds.token}/sendMessage`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: creds.chatId,
|
||||||
|
text: '🔔 <b>UI Stock Tracker</b> — Test notification! Alerts are working correctly.',
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json() as { description?: string };
|
||||||
|
return { success: false, error: body.description || 'Unknown Telegram error' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/tsconfig.json
Normal file
18
backend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
container_name: ui-tracker-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3001
|
||||||
|
- DATABASE_PATH=/app/data/tracker.db
|
||||||
|
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
container_name: ui-tracker-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||||
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||||
|
FROM nginx:stable-alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>UI Stock Tracker</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📦</text></svg>" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
frontend/nginx.conf
Normal file
28
frontend/nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://ui-tracker-backend:3001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "ui-tracker-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.378.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.2.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
161
frontend/src/App.tsx
Normal file
161
frontend/src/App.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Package, Plus, Settings as SettingsIcon } from 'lucide-react';
|
||||||
|
import type { WatchedItem, Settings } from './types';
|
||||||
|
import { getItems, getSettings } from './api/client';
|
||||||
|
import ItemCard from './components/ItemCard';
|
||||||
|
import AddItemModal from './components/AddItemModal';
|
||||||
|
import EditItemModal from './components/EditItemModal';
|
||||||
|
import SettingsModal from './components/SettingsModal';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [items, setItems] = useState<WatchedItem[]>([]);
|
||||||
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [connected, setConnected] = useState<boolean | null>(null);
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [editItem, setEditItem] = useState<WatchedItem | null>(null);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
const fetchItems = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getItems();
|
||||||
|
setItems(data);
|
||||||
|
setConnected(true);
|
||||||
|
} catch {
|
||||||
|
setConnected(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
const id = setInterval(fetchItems, 10_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [fetchItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSettings().then(setSettings).catch(() => null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const inStock = items.filter(i => i.last_status === 'in_stock').length;
|
||||||
|
const active = items.filter(i => i.is_active === 1).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<header className="header">
|
||||||
|
<div className="header-inner">
|
||||||
|
<div className="logo">
|
||||||
|
<div className="logo-icon">
|
||||||
|
<Package size={18} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="logo-text">UI <span>Stock</span> Tracker</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header-stats">
|
||||||
|
<div className="stat-pill">
|
||||||
|
<strong>{items.length}</strong> Tracked
|
||||||
|
</div>
|
||||||
|
<div className="stat-pill active">
|
||||||
|
<strong>{active}</strong> Active
|
||||||
|
</div>
|
||||||
|
{inStock > 0 && (
|
||||||
|
<div className="stat-pill in-stock">
|
||||||
|
<strong>{inStock}</strong> In Stock
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header-spacer" />
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
<div
|
||||||
|
className={`conn-dot ${connected === true ? 'connected' : connected === false ? 'error' : ''}`}
|
||||||
|
title={connected === true ? 'Connected to backend' : connected === false ? 'Cannot reach backend' : 'Connecting…'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<SettingsIcon size={15} />
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
<button className="btn-primary" onClick={() => setShowAdd(true)}>
|
||||||
|
<Plus size={16} strokeWidth={2.5} />
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Main ── */}
|
||||||
|
<main className="main">
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading">
|
||||||
|
<div className="spinner" />
|
||||||
|
Connecting to backend…
|
||||||
|
</div>
|
||||||
|
) : connected === false ? (
|
||||||
|
<div className="error-banner">
|
||||||
|
⚠ Cannot reach the backend. Make sure the Docker container is running.
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-icon">
|
||||||
|
<Package size={36} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<h2>Nothing tracked yet</h2>
|
||||||
|
<p>
|
||||||
|
Add a product URL from <strong>store.ui.com</strong> and the tracker will alert you
|
||||||
|
the moment it comes back in stock.
|
||||||
|
</p>
|
||||||
|
<button className="btn-primary" onClick={() => setShowAdd(true)}>
|
||||||
|
<Plus size={16} strokeWidth={2.5} />
|
||||||
|
Add your first item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="section-header">
|
||||||
|
<span className="section-title">{items.length} item{items.length !== 1 ? 's' : ''} tracked</span>
|
||||||
|
</div>
|
||||||
|
<div className="items-grid">
|
||||||
|
{items.map(item => (
|
||||||
|
<ItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onEdit={setEditItem}
|
||||||
|
onRefresh={fetchItems}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* ── Modals ── */}
|
||||||
|
{showAdd && (
|
||||||
|
<AddItemModal
|
||||||
|
onClose={() => setShowAdd(false)}
|
||||||
|
onAdded={() => { setShowAdd(false); fetchItems(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editItem && (
|
||||||
|
<EditItemModal
|
||||||
|
item={editItem}
|
||||||
|
onClose={() => setEditItem(null)}
|
||||||
|
onSaved={() => { setEditItem(null); fetchItems(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showSettings && (
|
||||||
|
<SettingsModal
|
||||||
|
settings={settings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
onSaved={s => { setSettings(s); setShowSettings(false); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/src/api/client.ts
Normal file
47
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { WatchedItem, Settings } from '../types';
|
||||||
|
|
||||||
|
const BASE = '/api';
|
||||||
|
|
||||||
|
async function req<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||||
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getItems = () => req<WatchedItem[]>('/items');
|
||||||
|
|
||||||
|
export const addItem = (url: string, check_interval: number) =>
|
||||||
|
req<WatchedItem>('/items', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url, check_interval }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateItem = (id: number, data: { check_interval?: number; is_active?: number }) =>
|
||||||
|
req<WatchedItem>(`/items/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteItem = (id: number) => req<void>(`/items/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
|
export const pauseItem = (id: number) => req<WatchedItem>(`/items/${id}/pause`, { method: 'POST' });
|
||||||
|
export const resumeItem = (id: number) => req<WatchedItem>(`/items/${id}/resume`, { method: 'POST' });
|
||||||
|
export const resetItem = (id: number) => req<WatchedItem>(`/items/${id}/reset`, { method: 'POST' });
|
||||||
|
|
||||||
|
export const getSettings = () => req<Settings>('/settings');
|
||||||
|
|
||||||
|
export const updateSettings = (data: Partial<Settings>) =>
|
||||||
|
req<Settings>('/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const testTelegram = () =>
|
||||||
|
req<{ success: boolean; error?: string }>('/settings/test-telegram', { method: 'POST' });
|
||||||
102
frontend/src/components/AddItemModal.tsx
Normal file
102
frontend/src/components/AddItemModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { addItem } from '../api/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onAdded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddItemModal({ onClose, onAdded }: Props) {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [interval, setInterval] = useState('60');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const urlRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => { urlRef.current?.focus(); }, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed.startsWith('http')) {
|
||||||
|
setError('Please enter a valid https:// URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmed.includes('store.ui.com')) {
|
||||||
|
setError('URL must be from store.ui.com');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secs = Math.max(30, parseInt(interval, 10) || 60);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await addItem(trimmed, secs);
|
||||||
|
onAdded();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to add item');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="add-title">
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<div className="modal-title" id="add-title">Track a Product</div>
|
||||||
|
<div className="modal-subtitle">Paste a product URL from the Ubiquiti store</div>
|
||||||
|
</div>
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="form-error">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="add-url">Product URL</label>
|
||||||
|
<input
|
||||||
|
id="add-url"
|
||||||
|
ref={urlRef}
|
||||||
|
className="form-input"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://store.ui.com/us/en/products/..."
|
||||||
|
value={url}
|
||||||
|
onChange={e => setUrl(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="add-interval">Check Every (seconds)</label>
|
||||||
|
<input
|
||||||
|
id="add-interval"
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
min="30"
|
||||||
|
step="1"
|
||||||
|
value={interval}
|
||||||
|
onChange={e => setInterval(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="form-hint">Minimum 30 seconds. The name and thumbnail are fetched automatically after adding.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn-secondary" onClick={onClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
{loading ? <><div className="spinner" style={{ width: 14, height: 14 }} /> Adding…</> : 'Start Tracking'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/src/components/EditItemModal.tsx
Normal file
96
frontend/src/components/EditItemModal.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import type { WatchedItem } from '../types';
|
||||||
|
import { updateItem } from '../api/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: WatchedItem;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(item: WatchedItem): string {
|
||||||
|
if (item.name) return item.name;
|
||||||
|
try { return new URL(item.url).pathname.split('/').filter(Boolean).pop() ?? item.url; }
|
||||||
|
catch { return item.url; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditItemModal({ item, onClose, onSaved }: Props) {
|
||||||
|
const [interval, setInterval] = useState(String(item.check_interval));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const secs = Math.max(30, parseInt(interval, 10) || item.check_interval);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updateItem(item.id, { check_interval: secs });
|
||||||
|
onSaved();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to save changes');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="edit-title">
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<div className="modal-title" id="edit-title">Edit Item</div>
|
||||||
|
<div className="modal-subtitle" style={{ maxWidth: 360, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{displayName(item)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="form-error">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Product URL</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
value={item.url}
|
||||||
|
disabled
|
||||||
|
style={{ opacity: 0.5, cursor: 'not-allowed' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="edit-interval">Check Every (seconds)</label>
|
||||||
|
<input
|
||||||
|
id="edit-interval"
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
min="30"
|
||||||
|
step="1"
|
||||||
|
value={interval}
|
||||||
|
onChange={e => setInterval(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div className="form-hint">Minimum 30 seconds. The scheduler will restart with the new interval immediately.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn-secondary" onClick={onClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
|
{loading ? <><div className="spinner" style={{ width: 14, height: 14 }} /> Saving…</> : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
frontend/src/components/ItemCard.tsx
Normal file
172
frontend/src/components/ItemCard.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Pencil, Trash2, Pause, Play, RotateCcw, Package } from 'lucide-react';
|
||||||
|
import type { WatchedItem } from '../types';
|
||||||
|
import { pauseItem, resumeItem, resetItem, deleteItem } from '../api/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: WatchedItem;
|
||||||
|
onEdit: (item: WatchedItem) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInterval(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return s === 0 ? `${m}m` : `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (diff < 5) return 'Just now';
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
const m = Math.floor(diff / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
return `${Math.floor(h / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(item: WatchedItem): string {
|
||||||
|
if (item.name) return item.name;
|
||||||
|
try { return new URL(item.url).pathname.split('/').filter(Boolean).pop() ?? item.url; }
|
||||||
|
catch { return item.url; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ItemCard({ item, onEdit, onRefresh }: Props) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<unknown>) => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await fn(); await onRefresh(); }
|
||||||
|
catch (e) { console.error(e); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!confirm(`Remove "${displayName(item)}" from tracking?`)) return;
|
||||||
|
run(() => deleteItem(item.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusClass = item.last_status; // 'in_stock' | 'sold_out' | 'unknown'
|
||||||
|
const isPaused = item.is_active === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`item-card ${statusClass} ${isPaused ? 'paused' : ''}`}>
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className="card-status-bar" />
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Thumbnail */}
|
||||||
|
{item.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
className="card-thumb"
|
||||||
|
src={item.thumbnail_url}
|
||||||
|
alt={item.name ?? 'Product'}
|
||||||
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="card-thumb-placeholder">
|
||||||
|
<Package size={28} strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="card-info">
|
||||||
|
<div className="card-name" title={displayName(item)}>
|
||||||
|
{displayName(item)}
|
||||||
|
</div>
|
||||||
|
<div className="card-url">
|
||||||
|
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{item.url.replace('https://', '')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="card-badges">
|
||||||
|
{item.last_status === 'in_stock' && <span className="badge badge-in-stock">In Stock</span>}
|
||||||
|
{item.last_status === 'sold_out' && <span className="badge badge-sold-out">Sold Out</span>}
|
||||||
|
{item.last_status === 'unknown' && <span className="badge badge-unknown">Unknown</span>}
|
||||||
|
{isPaused && <span className="badge badge-paused">Paused</span>}
|
||||||
|
{item.alert_sent === 1 && <span className="badge badge-alerted" title="Alert fired — click Re-arm to enable again">Alert Sent ✓</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="card-meta">
|
||||||
|
<div className="card-meta-item" title="Total checks performed">
|
||||||
|
<span>Checks:</span>
|
||||||
|
<strong>{item.check_count.toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="card-meta-item" title="Check interval">
|
||||||
|
<span>Every</span>
|
||||||
|
<strong>{formatInterval(item.check_interval)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="card-meta-item" title="Last checked">
|
||||||
|
<span>{timeAgo(item.last_checked_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div className="card-footer">
|
||||||
|
{/* Pause / Resume */}
|
||||||
|
{isPaused ? (
|
||||||
|
<button
|
||||||
|
className="btn-icon success"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => run(() => resumeItem(item.id))}
|
||||||
|
title="Resume checking"
|
||||||
|
>
|
||||||
|
<Play size={15} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn-icon warning"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => run(() => pauseItem(item.id))}
|
||||||
|
title="Pause checking"
|
||||||
|
>
|
||||||
|
<Pause size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Re-arm alert */}
|
||||||
|
{item.alert_sent === 1 && (
|
||||||
|
<button
|
||||||
|
className="btn-ghost"
|
||||||
|
style={{ fontSize: '11px', padding: '5px 9px' }}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => run(() => resetItem(item.id))}
|
||||||
|
title="Re-arm: clear the sent-alert flag so you get a new notification next time it's in stock"
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
Re-arm
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit */}
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onEdit(item)}
|
||||||
|
title="Edit interval"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button
|
||||||
|
className="btn-icon danger"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={handleDelete}
|
||||||
|
title="Remove from tracking"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/components/SettingsModal.tsx
Normal file
150
frontend/src/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Send } from 'lucide-react';
|
||||||
|
import type { Settings } from '../types';
|
||||||
|
import { updateSettings, testTelegram } from '../api/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Settings | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (s: Settings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsModal({ settings, onClose, onSaved }: Props) {
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [chatId, setChatId] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [testMsg, setTestMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
setToken(settings.telegram_bot_token ?? '');
|
||||||
|
setChatId(settings.telegram_chat_id ?? '');
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setTestMsg('');
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await updateSettings({
|
||||||
|
telegram_bot_token: token.trim(),
|
||||||
|
telegram_chat_id: chatId.trim(),
|
||||||
|
});
|
||||||
|
onSaved(updated);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to save settings');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTestMsg('');
|
||||||
|
setError('');
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
// Save first so the test uses current values
|
||||||
|
await updateSettings({
|
||||||
|
telegram_bot_token: token.trim(),
|
||||||
|
telegram_chat_id: chatId.trim(),
|
||||||
|
});
|
||||||
|
const result = await testTelegram();
|
||||||
|
if (result.success) {
|
||||||
|
setTestMsg('✓ Test message sent! Check your Telegram.');
|
||||||
|
} else {
|
||||||
|
setError(result.error ?? 'Test failed — check your token and chat ID.');
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Test failed');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<div className="modal-title" id="settings-title">Telegram Settings</div>
|
||||||
|
<div className="modal-subtitle">Configure your alert notifications</div>
|
||||||
|
</div>
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-box">
|
||||||
|
<strong>Getting your Chat ID</strong><br />
|
||||||
|
Your Telegram <em>username</em> (@ALWISPER) can't be used directly — you need your numeric
|
||||||
|
chat ID. The easiest way: message{' '}
|
||||||
|
<strong>@userinfobot</strong> on Telegram and it will reply with your numeric ID.
|
||||||
|
Then paste it below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="form-error">{error}</div>}
|
||||||
|
{testMsg && <div className="success-box">{testMsg}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="tg-token">Bot Token</label>
|
||||||
|
<input
|
||||||
|
id="tg-token"
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="1234567890:AABBCCDDEEFFaabbccddeeff..."
|
||||||
|
value={token}
|
||||||
|
onChange={e => setToken(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div className="form-hint">
|
||||||
|
From <strong>@BotFather</strong> → your bot → API Token.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label" htmlFor="tg-chatid">Chat ID (numeric)</label>
|
||||||
|
<input
|
||||||
|
id="tg-chatid"
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="123456789"
|
||||||
|
value={chatId}
|
||||||
|
onChange={e => setChatId(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div className="form-hint">
|
||||||
|
Message <strong>@userinfobot</strong> to get your numeric ID.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
disabled={testing || saving || !token || !chatId}
|
||||||
|
onClick={handleTest}
|
||||||
|
>
|
||||||
|
{testing
|
||||||
|
? <><div className="spinner" style={{ width: 13, height: 13 }} /> Sending…</>
|
||||||
|
: <><Send size={13} /> Test Alert</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-secondary" onClick={onClose} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary" disabled={saving}>
|
||||||
|
{saving ? <><div className="spinner" style={{ width: 14, height: 14 }} /> Saving…</> : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
593
frontend/src/index.css
Normal file
593
frontend/src/index.css
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
/* ── Variables ──────────────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #080c14;
|
||||||
|
--bg-card: #0f1825;
|
||||||
|
--bg-elevated: #162030;
|
||||||
|
--bg-input: #0a1220;
|
||||||
|
--border: rgba(255, 255, 255, 0.07);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.13);
|
||||||
|
|
||||||
|
--accent: #006fff;
|
||||||
|
--accent-hover: #0056cc;
|
||||||
|
--accent-dim: rgba(0, 111, 255, 0.12);
|
||||||
|
|
||||||
|
--success: #00c853;
|
||||||
|
--success-dim: rgba(0, 200, 83, 0.12);
|
||||||
|
--danger: #ff4444;
|
||||||
|
--danger-dim: rgba(255, 68, 68, 0.12);
|
||||||
|
--warning: #ffaa00;
|
||||||
|
--warning-dim: rgba(255, 170, 0, 0.12);
|
||||||
|
--muted: #4a5778;
|
||||||
|
|
||||||
|
--text: #f0f4ff;
|
||||||
|
--text-secondary:#7a8ba5;
|
||||||
|
--text-muted: #3d4f6a;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 18px;
|
||||||
|
|
||||||
|
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
--shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.65);
|
||||||
|
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ──────────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── App shell ──────────────────────────────────────────────────────── */
|
||||||
|
.app { min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||||
|
.header {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 28px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 200;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.header-inner {
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo */
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.logo-icon {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.logo-text {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.4px;
|
||||||
|
}
|
||||||
|
.logo-text span { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Stats pills */
|
||||||
|
.header-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.stat-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.stat-pill strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.stat-pill.active strong { color: var(--accent); }
|
||||||
|
.stat-pill.in-stock strong { color: var(--success); }
|
||||||
|
|
||||||
|
/* Connection dot */
|
||||||
|
.conn-dot {
|
||||||
|
width: 7px; height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
transition: background 0.4s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.conn-dot.connected { background: var(--success); }
|
||||||
|
.conn-dot.error { background: var(--danger); }
|
||||||
|
|
||||||
|
.header-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
|
||||||
|
/* ── Buttons ─────────────────────────────────────────────────────────── */
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
button:active { transform: scale(0.97); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { color: var(--text); border-color: rgba(255,255,255,0.22); }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 7px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
.btn-icon:hover { background: var(--bg-elevated); color: var(--text-secondary); }
|
||||||
|
.btn-icon.danger:hover { color: var(--danger); background: var(--danger-dim); }
|
||||||
|
.btn-icon.success:hover { color: var(--success); background: var(--success-dim); }
|
||||||
|
.btn-icon.warning:hover { color: var(--warning); background: var(--warning-dim); }
|
||||||
|
|
||||||
|
/* ── Main content ────────────────────────────────────────────────────── */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px 28px 48px;
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section header ──────────────────────────────────────────────────── */
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Items grid ──────────────────────────────────────────────────────── */
|
||||||
|
.items-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Item card ───────────────────────────────────────────────────────── */
|
||||||
|
.item-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.item-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
.item-card.paused { opacity: 0.65; }
|
||||||
|
|
||||||
|
/* Top accent bar */
|
||||||
|
.card-status-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.item-card.in_stock .card-status-bar { background: var(--success); }
|
||||||
|
.item-card.sold_out .card-status-bar { background: var(--danger); }
|
||||||
|
.item-card.unknown .card-status-bar { background: var(--muted); }
|
||||||
|
|
||||||
|
/* Card body */
|
||||||
|
.card-body {
|
||||||
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnail */
|
||||||
|
.card-thumb {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.card-thumb-placeholder {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info section */
|
||||||
|
.card-info { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-url {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.card-url a { color: inherit; text-decoration: none; }
|
||||||
|
.card-url a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Badges row */
|
||||||
|
.card-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.badge-in-stock { background: var(--success-dim); color: var(--success); border-color: rgba(0,200,83,0.25); }
|
||||||
|
.badge-sold-out { background: var(--danger-dim); color: var(--danger); border-color: rgba(255,68,68,0.25); }
|
||||||
|
.badge-unknown { background: rgba(74,87,104,0.2); color: var(--text-secondary); border-color: rgba(74,87,104,0.3); }
|
||||||
|
.badge-paused { background: var(--warning-dim); color: var(--warning); border-color: rgba(255,170,0,0.25); }
|
||||||
|
.badge-alerted { background: var(--accent-dim); color: var(--accent); border-color: rgba(0,111,255,0.25); }
|
||||||
|
|
||||||
|
/* Meta row */
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.card-meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.card-meta-item strong {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card footer / actions */
|
||||||
|
.card-footer {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100px 24px;
|
||||||
|
text-align: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.empty-icon {
|
||||||
|
width: 72px; height: 72px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.empty-state h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 380px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ───────────────────────────────────────────────────────────── */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.72);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 500;
|
||||||
|
padding: 24px;
|
||||||
|
animation: backdrop-in 0.15s ease;
|
||||||
|
}
|
||||||
|
@keyframes backdrop-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: var(--shadow-modal);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 28px;
|
||||||
|
animation: modal-in 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
@keyframes modal-in {
|
||||||
|
from { opacity: 0; transform: scale(0.94) translateY(10px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.modal-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.modal-close:hover { background: var(--bg-elevated); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Forms ───────────────────────────────────────────────────────────── */
|
||||||
|
.form-group { margin-bottom: 18px; }
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 111, 255, 0.15);
|
||||||
|
}
|
||||||
|
.form-input::placeholder { color: var(--text-muted); }
|
||||||
|
.form-input[type="number"] { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.form-hint a { color: var(--accent); text-decoration: none; }
|
||||||
|
.form-hint a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.form-row { display: flex; gap: 14px; }
|
||||||
|
.form-row .form-group { flex: 1; }
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error inline */
|
||||||
|
.form-error {
|
||||||
|
background: var(--danger-dim);
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.25);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Info box (settings) ─────────────────────────────────────────────── */
|
||||||
|
.info-box {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border: 1px solid rgba(0, 111, 255, 0.2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.info-box strong { color: var(--text); }
|
||||||
|
|
||||||
|
/* Success box */
|
||||||
|
.success-box {
|
||||||
|
background: var(--success-dim);
|
||||||
|
border: 1px solid rgba(0, 200, 83, 0.25);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--success);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading / Error states ──────────────────────────────────────────── */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 200px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
border: 2px solid var(--border-strong);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: var(--danger-dim);
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.22);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tooltip ─────────────────────────────────────────────────────────── */
|
||||||
|
[title] { cursor: help; }
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header { padding: 0 16px; }
|
||||||
|
.header-stats { display: none; }
|
||||||
|
.main { padding: 20px 16px 40px; }
|
||||||
|
.items-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
.modal { padding: 22px 18px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.logo-text { font-size: 15px; }
|
||||||
|
.form-row { flex-direction: column; gap: 0; }
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
20
frontend/src/types/index.ts
Normal file
20
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export type StockStatus = 'in_stock' | 'sold_out' | 'unknown';
|
||||||
|
|
||||||
|
export interface WatchedItem {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
name: string | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
check_interval: number;
|
||||||
|
is_active: number;
|
||||||
|
last_status: StockStatus;
|
||||||
|
alert_sent: number;
|
||||||
|
check_count: number;
|
||||||
|
last_checked_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
telegram_bot_token: string;
|
||||||
|
telegram_chat_id: string;
|
||||||
|
}
|
||||||
18
frontend/tsconfig.json
Normal file
18
frontend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user