From 6f7c0388341706fee06c16015bcc11f493f48e09 Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 27 Mar 2026 23:33:31 -0500 Subject: [PATCH] build 1 --- .gitignore | 6 + backend/Dockerfile | 37 ++ backend/package.json | 24 + backend/src/database.ts | 57 +++ backend/src/index.ts | 27 + backend/src/routes/items.ts | 122 +++++ backend/src/routes/settings.ts | 36 ++ backend/src/scheduler.ts | 85 ++++ backend/src/scraper.ts | 113 +++++ backend/src/telegram.ts | 67 +++ backend/tsconfig.json | 18 + data/.gitkeep | 0 docker-compose.yml | 25 + frontend/Dockerfile | 19 + frontend/index.html | 13 + frontend/nginx.conf | 28 + frontend/package.json | 22 + frontend/src/App.tsx | 161 ++++++ frontend/src/api/client.ts | 47 ++ frontend/src/components/AddItemModal.tsx | 102 ++++ frontend/src/components/EditItemModal.tsx | 96 ++++ frontend/src/components/ItemCard.tsx | 172 +++++++ frontend/src/components/SettingsModal.tsx | 150 ++++++ frontend/src/index.css | 593 ++++++++++++++++++++++ frontend/src/main.tsx | 10 + frontend/src/types/index.ts | 20 + frontend/tsconfig.json | 18 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 15 + 29 files changed, 2093 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/database.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/routes/items.ts create mode 100644 backend/src/routes/settings.ts create mode 100644 backend/src/scheduler.ts create mode 100644 backend/src/scraper.ts create mode 100644 backend/src/telegram.ts create mode 100644 backend/tsconfig.json create mode 100644 data/.gitkeep create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/AddItemModal.tsx create mode 100644 frontend/src/components/EditItemModal.tsx create mode 100644 frontend/src/components/ItemCard.tsx create mode 100644 frontend/src/components/SettingsModal.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d00b862 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +data/*.db +*.log +.env +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d528b68 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..bf76c22 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/database.ts b/backend/src/database.ts new file mode 100644 index 0000000..7ac6362 --- /dev/null +++ b/backend/src/database.ts @@ -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; +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..17d8a73 --- /dev/null +++ b/backend/src/index.ts @@ -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}`); +}); diff --git a/backend/src/routes/items.ts b/backend/src/routes/items.ts new file mode 100644 index 0000000..f0cc54c --- /dev/null +++ b/backend/src/routes/items.ts @@ -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 = { 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; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 0000000..a8f4176 --- /dev/null +++ b/backend/src/routes/settings.ts @@ -0,0 +1,36 @@ +import { Router, Request, Response } from 'express'; +import { getDb } from '../database'; +import { testTelegramConnection } from '../telegram'; + +const router = Router(); + +function getAllSettings(): Record { + 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; + 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; diff --git a/backend/src/scheduler.ts b/backend/src/scheduler.ts new file mode 100644 index 0000000..fee45a5 --- /dev/null +++ b/backend/src/scheduler.ts @@ -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(); + +async function runCheck(itemId: number): Promise { + 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( + `🟢 Back in Stock!\n\n` + + `${displayName}\n\n` + + `Open in Store →` + ); + alertSent = 1; + } + + // Build update payload + const updates: Record = { + 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)`); +} diff --git a/backend/src/scraper.ts b/backend/src/scraper.ts new file mode 100644 index 0000000..ca5f321 --- /dev/null +++ b/backend/src/scraper.ts @@ -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 { + 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 { + 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(); + } +} diff --git a/backend/src/telegram.ts b/backend/src/telegram.ts new file mode 100644 index 0000000..2adde17 --- /dev/null +++ b/backend/src/telegram.ts @@ -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 { + 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: '🔔 UI Stock Tracker — 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) }; + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..ae6c30e --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b12a871 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..60a189c --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..83869c4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + UI Stock Tracker + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..0f3c143 --- /dev/null +++ b/frontend/nginx.conf @@ -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"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1ab7122 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..59f0389 --- /dev/null +++ b/frontend/src/App.tsx @@ -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([]); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [connected, setConnected] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [editItem, setEditItem] = useState(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 ( +
+ {/* ── Header ── */} +
+
+
+
+ +
+ UI Stock Tracker +
+ +
+
+ {items.length} Tracked +
+
+ {active} Active +
+ {inStock > 0 && ( +
+ {inStock} In Stock +
+ )} +
+ +
+ +
+
+ + +
+
+
+ + {/* ── Main ── */} +
+ {loading ? ( +
+
+ Connecting to backend… +
+ ) : connected === false ? ( +
+ ⚠ Cannot reach the backend. Make sure the Docker container is running. +
+ ) : items.length === 0 ? ( +
+
+ +
+

Nothing tracked yet

+

+ Add a product URL from store.ui.com and the tracker will alert you + the moment it comes back in stock. +

+ +
+ ) : ( + <> +
+ {items.length} item{items.length !== 1 ? 's' : ''} tracked +
+
+ {items.map(item => ( + + ))} +
+ + )} +
+ + {/* ── Modals ── */} + {showAdd && ( + setShowAdd(false)} + onAdded={() => { setShowAdd(false); fetchItems(); }} + /> + )} + {editItem && ( + setEditItem(null)} + onSaved={() => { setEditItem(null); fetchItems(); }} + /> + )} + {showSettings && ( + setShowSettings(false)} + onSaved={s => { setSettings(s); setShowSettings(false); }} + /> + )} +
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..8d676c0 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,47 @@ +import type { WatchedItem, Settings } from '../types'; + +const BASE = '/api'; + +async function req(path: string, options?: RequestInit): Promise { + 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; +} + +export const getItems = () => req('/items'); + +export const addItem = (url: string, check_interval: number) => + req('/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(`/items/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + +export const deleteItem = (id: number) => req(`/items/${id}`, { method: 'DELETE' }); + +export const pauseItem = (id: number) => req(`/items/${id}/pause`, { method: 'POST' }); +export const resumeItem = (id: number) => req(`/items/${id}/resume`, { method: 'POST' }); +export const resetItem = (id: number) => req(`/items/${id}/reset`, { method: 'POST' }); + +export const getSettings = () => req('/settings'); + +export const updateSettings = (data: Partial) => + req('/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' }); diff --git a/frontend/src/components/AddItemModal.tsx b/frontend/src/components/AddItemModal.tsx new file mode 100644 index 0000000..6798ab9 --- /dev/null +++ b/frontend/src/components/AddItemModal.tsx @@ -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(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 ( +
e.target === e.currentTarget && onClose()}> +
+
+
+
Track a Product
+
Paste a product URL from the Ubiquiti store
+
+ +
+ + {error &&
{error}
} + +
+
+ + setUrl(e.target.value)} + required + /> +
+ +
+ + setInterval(e.target.value)} + required + /> +
Minimum 30 seconds. The name and thumbnail are fetched automatically after adding.
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/EditItemModal.tsx b/frontend/src/components/EditItemModal.tsx new file mode 100644 index 0000000..b91aac8 --- /dev/null +++ b/frontend/src/components/EditItemModal.tsx @@ -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 ( +
e.target === e.currentTarget && onClose()}> +
+
+
+
Edit Item
+
+ {displayName(item)} +
+
+ +
+ + {error &&
{error}
} + +
+
+ + +
+ +
+ + setInterval(e.target.value)} + autoFocus + required + /> +
Minimum 30 seconds. The scheduler will restart with the new interval immediately.
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ItemCard.tsx b/frontend/src/components/ItemCard.tsx new file mode 100644 index 0000000..dfd9c53 --- /dev/null +++ b/frontend/src/components/ItemCard.tsx @@ -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) => { + 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 ( +
+ {/* Status bar */} +
+ + {/* Body */} +
+ {/* Thumbnail */} + {item.thumbnail_url ? ( + {item.name { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + ) : ( +
+ +
+ )} + + {/* Info */} +
+
+ {displayName(item)} +
+ + + {/* Badges */} +
+ {item.last_status === 'in_stock' && In Stock} + {item.last_status === 'sold_out' && Sold Out} + {item.last_status === 'unknown' && Unknown} + {isPaused && Paused} + {item.alert_sent === 1 && Alert Sent ✓} +
+ + {/* Meta */} +
+
+ Checks: + {item.check_count.toLocaleString()} +
+
+ Every + {formatInterval(item.check_interval)} +
+
+ {timeAgo(item.last_checked_at)} +
+
+
+
+ + {/* Footer actions */} +
+ {/* Pause / Resume */} + {isPaused ? ( + + ) : ( + + )} + + {/* Re-arm alert */} + {item.alert_sent === 1 && ( + + )} + + {/* Edit */} + + + {/* Delete */} + +
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx new file mode 100644 index 0000000..7904dae --- /dev/null +++ b/frontend/src/components/SettingsModal.tsx @@ -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 ( +
e.target === e.currentTarget && onClose()}> +
+
+
+
Telegram Settings
+
Configure your alert notifications
+
+ +
+ +
+ Getting your Chat ID
+ Your Telegram username (@ALWISPER) can't be used directly — you need your numeric + chat ID. The easiest way: message{' '} + @userinfobot on Telegram and it will reply with your numeric ID. + Then paste it below. +
+ + {error &&
{error}
} + {testMsg &&
{testMsg}
} + +
+
+ + setToken(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ From @BotFather → your bot → API Token. +
+
+ +
+ + setChatId(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ Message @userinfobot to get your numeric ID. +
+
+ +
+ + + +
+
+
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7760144 --- /dev/null +++ b/frontend/src/index.css @@ -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; } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..2339d59 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + +); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..298a2c3 --- /dev/null +++ b/frontend/src/types/index.ts @@ -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; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2e31274 --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4414e51 --- /dev/null +++ b/frontend/vite.config.ts @@ -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, + }, + }, + }, +});