This commit is contained in:
2026-03-27 23:33:31 -05:00
parent 9fa1df47ad
commit 6f7c038834
29 changed files with 2093 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
data/*.db
*.log
.env
.DS_Store

37
backend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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
View 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
View File

25
docker-compose.yml Normal file
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

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

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

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

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

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

View 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
View 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" }]
}

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