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