build 1
This commit is contained in:
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UI Stock Tracker</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📦</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/nginx.conf
Normal file
28
frontend/nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://ui-tracker-backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "ui-tracker-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.378.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11"
|
||||
}
|
||||
}
|
||||
161
frontend/src/App.tsx
Normal file
161
frontend/src/App.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Package, Plus, Settings as SettingsIcon } from 'lucide-react';
|
||||
import type { WatchedItem, Settings } from './types';
|
||||
import { getItems, getSettings } from './api/client';
|
||||
import ItemCard from './components/ItemCard';
|
||||
import AddItemModal from './components/AddItemModal';
|
||||
import EditItemModal from './components/EditItemModal';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
|
||||
export default function App() {
|
||||
const [items, setItems] = useState<WatchedItem[]>([]);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [connected, setConnected] = useState<boolean | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editItem, setEditItem] = useState<WatchedItem | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const data = await getItems();
|
||||
setItems(data);
|
||||
setConnected(true);
|
||||
} catch {
|
||||
setConnected(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
const id = setInterval(fetchItems, 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchItems]);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings().then(setSettings).catch(() => null);
|
||||
}, []);
|
||||
|
||||
const inStock = items.filter(i => i.last_status === 'in_stock').length;
|
||||
const active = items.filter(i => i.is_active === 1).length;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* ── Header ── */}
|
||||
<header className="header">
|
||||
<div className="header-inner">
|
||||
<div className="logo">
|
||||
<div className="logo-icon">
|
||||
<Package size={18} strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="logo-text">UI <span>Stock</span> Tracker</span>
|
||||
</div>
|
||||
|
||||
<div className="header-stats">
|
||||
<div className="stat-pill">
|
||||
<strong>{items.length}</strong> Tracked
|
||||
</div>
|
||||
<div className="stat-pill active">
|
||||
<strong>{active}</strong> Active
|
||||
</div>
|
||||
{inStock > 0 && (
|
||||
<div className="stat-pill in-stock">
|
||||
<strong>{inStock}</strong> In Stock
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="header-spacer" />
|
||||
|
||||
<div className="header-actions">
|
||||
<div
|
||||
className={`conn-dot ${connected === true ? 'connected' : connected === false ? 'error' : ''}`}
|
||||
title={connected === true ? 'Connected to backend' : connected === false ? 'Cannot reach backend' : 'Connecting…'}
|
||||
/>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => setShowSettings(true)}
|
||||
title="Settings"
|
||||
>
|
||||
<SettingsIcon size={15} />
|
||||
Settings
|
||||
</button>
|
||||
<button className="btn-primary" onClick={() => setShowAdd(true)}>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Main ── */}
|
||||
<main className="main">
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
<div className="spinner" />
|
||||
Connecting to backend…
|
||||
</div>
|
||||
) : connected === false ? (
|
||||
<div className="error-banner">
|
||||
⚠ Cannot reach the backend. Make sure the Docker container is running.
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">
|
||||
<Package size={36} strokeWidth={1.5} />
|
||||
</div>
|
||||
<h2>Nothing tracked yet</h2>
|
||||
<p>
|
||||
Add a product URL from <strong>store.ui.com</strong> and the tracker will alert you
|
||||
the moment it comes back in stock.
|
||||
</p>
|
||||
<button className="btn-primary" onClick={() => setShowAdd(true)}>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
Add your first item
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="section-header">
|
||||
<span className="section-title">{items.length} item{items.length !== 1 ? 's' : ''} tracked</span>
|
||||
</div>
|
||||
<div className="items-grid">
|
||||
{items.map(item => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onEdit={setEditItem}
|
||||
onRefresh={fetchItems}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* ── Modals ── */}
|
||||
{showAdd && (
|
||||
<AddItemModal
|
||||
onClose={() => setShowAdd(false)}
|
||||
onAdded={() => { setShowAdd(false); fetchItems(); }}
|
||||
/>
|
||||
)}
|
||||
{editItem && (
|
||||
<EditItemModal
|
||||
item={editItem}
|
||||
onClose={() => setEditItem(null)}
|
||||
onSaved={() => { setEditItem(null); fetchItems(); }}
|
||||
/>
|
||||
)}
|
||||
{showSettings && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onSaved={s => { setSettings(s); setShowSettings(false); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/api/client.ts
Normal file
47
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { WatchedItem, Settings } from '../types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function req<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, options);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const getItems = () => req<WatchedItem[]>('/items');
|
||||
|
||||
export const addItem = (url: string, check_interval: number) =>
|
||||
req<WatchedItem>('/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, check_interval }),
|
||||
});
|
||||
|
||||
export const updateItem = (id: number, data: { check_interval?: number; is_active?: number }) =>
|
||||
req<WatchedItem>(`/items/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
export const deleteItem = (id: number) => req<void>(`/items/${id}`, { method: 'DELETE' });
|
||||
|
||||
export const pauseItem = (id: number) => req<WatchedItem>(`/items/${id}/pause`, { method: 'POST' });
|
||||
export const resumeItem = (id: number) => req<WatchedItem>(`/items/${id}/resume`, { method: 'POST' });
|
||||
export const resetItem = (id: number) => req<WatchedItem>(`/items/${id}/reset`, { method: 'POST' });
|
||||
|
||||
export const getSettings = () => req<Settings>('/settings');
|
||||
|
||||
export const updateSettings = (data: Partial<Settings>) =>
|
||||
req<Settings>('/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
export const testTelegram = () =>
|
||||
req<{ success: boolean; error?: string }>('/settings/test-telegram', { method: 'POST' });
|
||||
102
frontend/src/components/AddItemModal.tsx
Normal file
102
frontend/src/components/AddItemModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { addItem } from '../api/client';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onAdded: () => void;
|
||||
}
|
||||
|
||||
export default function AddItemModal({ onClose, onAdded }: Props) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [interval, setInterval] = useState('60');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => { urlRef.current?.focus(); }, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed.startsWith('http')) {
|
||||
setError('Please enter a valid https:// URL');
|
||||
return;
|
||||
}
|
||||
if (!trimmed.includes('store.ui.com')) {
|
||||
setError('URL must be from store.ui.com');
|
||||
return;
|
||||
}
|
||||
|
||||
const secs = Math.max(30, parseInt(interval, 10) || 60);
|
||||
setLoading(true);
|
||||
try {
|
||||
await addItem(trimmed, secs);
|
||||
onAdded();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to add item');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="add-title">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<div className="modal-title" id="add-title">Track a Product</div>
|
||||
<div className="modal-subtitle">Paste a product URL from the Ubiquiti store</div>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="add-url">Product URL</label>
|
||||
<input
|
||||
id="add-url"
|
||||
ref={urlRef}
|
||||
className="form-input"
|
||||
type="url"
|
||||
placeholder="https://store.ui.com/us/en/products/..."
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="add-interval">Check Every (seconds)</label>
|
||||
<input
|
||||
id="add-interval"
|
||||
className="form-input"
|
||||
type="number"
|
||||
min="30"
|
||||
step="1"
|
||||
value={interval}
|
||||
onChange={e => setInterval(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="form-hint">Minimum 30 seconds. The name and thumbnail are fetched automatically after adding.</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? <><div className="spinner" style={{ width: 14, height: 14 }} /> Adding…</> : 'Start Tracking'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
frontend/src/components/EditItemModal.tsx
Normal file
96
frontend/src/components/EditItemModal.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { WatchedItem } from '../types';
|
||||
import { updateItem } from '../api/client';
|
||||
|
||||
interface Props {
|
||||
item: WatchedItem;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function displayName(item: WatchedItem): string {
|
||||
if (item.name) return item.name;
|
||||
try { return new URL(item.url).pathname.split('/').filter(Boolean).pop() ?? item.url; }
|
||||
catch { return item.url; }
|
||||
}
|
||||
|
||||
export default function EditItemModal({ item, onClose, onSaved }: Props) {
|
||||
const [interval, setInterval] = useState(String(item.check_interval));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
const secs = Math.max(30, parseInt(interval, 10) || item.check_interval);
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateItem(item.id, { check_interval: secs });
|
||||
onSaved();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to save changes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="edit-title">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<div className="modal-title" id="edit-title">Edit Item</div>
|
||||
<div className="modal-subtitle" style={{ maxWidth: 360, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{displayName(item)}
|
||||
</div>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Product URL</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="text"
|
||||
value={item.url}
|
||||
disabled
|
||||
style={{ opacity: 0.5, cursor: 'not-allowed' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="edit-interval">Check Every (seconds)</label>
|
||||
<input
|
||||
id="edit-interval"
|
||||
className="form-input"
|
||||
type="number"
|
||||
min="30"
|
||||
step="1"
|
||||
value={interval}
|
||||
onChange={e => setInterval(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<div className="form-hint">Minimum 30 seconds. The scheduler will restart with the new interval immediately.</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? <><div className="spinner" style={{ width: 14, height: 14 }} /> Saving…</> : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
frontend/src/components/ItemCard.tsx
Normal file
172
frontend/src/components/ItemCard.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react';
|
||||
import { Pencil, Trash2, Pause, Play, RotateCcw, Package } from 'lucide-react';
|
||||
import type { WatchedItem } from '../types';
|
||||
import { pauseItem, resumeItem, resetItem, deleteItem } from '../api/client';
|
||||
|
||||
interface Props {
|
||||
item: WatchedItem;
|
||||
onEdit: (item: WatchedItem) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function formatInterval(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return s === 0 ? `${m}m` : `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return 'Never';
|
||||
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (diff < 5) return 'Just now';
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
const m = Math.floor(diff / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
function displayName(item: WatchedItem): string {
|
||||
if (item.name) return item.name;
|
||||
try { return new URL(item.url).pathname.split('/').filter(Boolean).pop() ?? item.url; }
|
||||
catch { return item.url; }
|
||||
}
|
||||
|
||||
export default function ItemCard({ item, onEdit, onRefresh }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try { await fn(); await onRefresh(); }
|
||||
catch (e) { console.error(e); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(`Remove "${displayName(item)}" from tracking?`)) return;
|
||||
run(() => deleteItem(item.id));
|
||||
};
|
||||
|
||||
const statusClass = item.last_status; // 'in_stock' | 'sold_out' | 'unknown'
|
||||
const isPaused = item.is_active === 0;
|
||||
|
||||
return (
|
||||
<div className={`item-card ${statusClass} ${isPaused ? 'paused' : ''}`}>
|
||||
{/* Status bar */}
|
||||
<div className="card-status-bar" />
|
||||
|
||||
{/* Body */}
|
||||
<div className="card-body">
|
||||
{/* Thumbnail */}
|
||||
{item.thumbnail_url ? (
|
||||
<img
|
||||
className="card-thumb"
|
||||
src={item.thumbnail_url}
|
||||
alt={item.name ?? 'Product'}
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="card-thumb-placeholder">
|
||||
<Package size={28} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="card-info">
|
||||
<div className="card-name" title={displayName(item)}>
|
||||
{displayName(item)}
|
||||
</div>
|
||||
<div className="card-url">
|
||||
<a href={item.url} target="_blank" rel="noopener noreferrer">
|
||||
{item.url.replace('https://', '')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="card-badges">
|
||||
{item.last_status === 'in_stock' && <span className="badge badge-in-stock">In Stock</span>}
|
||||
{item.last_status === 'sold_out' && <span className="badge badge-sold-out">Sold Out</span>}
|
||||
{item.last_status === 'unknown' && <span className="badge badge-unknown">Unknown</span>}
|
||||
{isPaused && <span className="badge badge-paused">Paused</span>}
|
||||
{item.alert_sent === 1 && <span className="badge badge-alerted" title="Alert fired — click Re-arm to enable again">Alert Sent ✓</span>}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="card-meta">
|
||||
<div className="card-meta-item" title="Total checks performed">
|
||||
<span>Checks:</span>
|
||||
<strong>{item.check_count.toLocaleString()}</strong>
|
||||
</div>
|
||||
<div className="card-meta-item" title="Check interval">
|
||||
<span>Every</span>
|
||||
<strong>{formatInterval(item.check_interval)}</strong>
|
||||
</div>
|
||||
<div className="card-meta-item" title="Last checked">
|
||||
<span>{timeAgo(item.last_checked_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="card-footer">
|
||||
{/* Pause / Resume */}
|
||||
{isPaused ? (
|
||||
<button
|
||||
className="btn-icon success"
|
||||
disabled={busy}
|
||||
onClick={() => run(() => resumeItem(item.id))}
|
||||
title="Resume checking"
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn-icon warning"
|
||||
disabled={busy}
|
||||
onClick={() => run(() => pauseItem(item.id))}
|
||||
title="Pause checking"
|
||||
>
|
||||
<Pause size={15} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Re-arm alert */}
|
||||
{item.alert_sent === 1 && (
|
||||
<button
|
||||
className="btn-ghost"
|
||||
style={{ fontSize: '11px', padding: '5px 9px' }}
|
||||
disabled={busy}
|
||||
onClick={() => run(() => resetItem(item.id))}
|
||||
title="Re-arm: clear the sent-alert flag so you get a new notification next time it's in stock"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
Re-arm
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit */}
|
||||
<button
|
||||
className="btn-icon"
|
||||
disabled={busy}
|
||||
onClick={() => onEdit(item)}
|
||||
title="Edit interval"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
className="btn-icon danger"
|
||||
disabled={busy}
|
||||
onClick={handleDelete}
|
||||
title="Remove from tracking"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
frontend/src/components/SettingsModal.tsx
Normal file
150
frontend/src/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Send } from 'lucide-react';
|
||||
import type { Settings } from '../types';
|
||||
import { updateSettings, testTelegram } from '../api/client';
|
||||
|
||||
interface Props {
|
||||
settings: Settings | null;
|
||||
onClose: () => void;
|
||||
onSaved: (s: Settings) => void;
|
||||
}
|
||||
|
||||
export default function SettingsModal({ settings, onClose, onSaved }: Props) {
|
||||
const [token, setToken] = useState('');
|
||||
const [chatId, setChatId] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [testMsg, setTestMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setToken(settings.telegram_bot_token ?? '');
|
||||
setChatId(settings.telegram_chat_id ?? '');
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setTestMsg('');
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateSettings({
|
||||
telegram_bot_token: token.trim(),
|
||||
telegram_chat_id: chatId.trim(),
|
||||
});
|
||||
onSaved(updated);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTestMsg('');
|
||||
setError('');
|
||||
setTesting(true);
|
||||
try {
|
||||
// Save first so the test uses current values
|
||||
await updateSettings({
|
||||
telegram_bot_token: token.trim(),
|
||||
telegram_chat_id: chatId.trim(),
|
||||
});
|
||||
const result = await testTelegram();
|
||||
if (result.success) {
|
||||
setTestMsg('✓ Test message sent! Check your Telegram.');
|
||||
} else {
|
||||
setError(result.error ?? 'Test failed — check your token and chat ID.');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Test failed');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<div className="modal-title" id="settings-title">Telegram Settings</div>
|
||||
<div className="modal-subtitle">Configure your alert notifications</div>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<strong>Getting your Chat ID</strong><br />
|
||||
Your Telegram <em>username</em> (@ALWISPER) can't be used directly — you need your numeric
|
||||
chat ID. The easiest way: message{' '}
|
||||
<strong>@userinfobot</strong> on Telegram and it will reply with your numeric ID.
|
||||
Then paste it below.
|
||||
</div>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
{testMsg && <div className="success-box">{testMsg}</div>}
|
||||
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="tg-token">Bot Token</label>
|
||||
<input
|
||||
id="tg-token"
|
||||
className="form-input"
|
||||
type="text"
|
||||
placeholder="1234567890:AABBCCDDEEFFaabbccddeeff..."
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="form-hint">
|
||||
From <strong>@BotFather</strong> → your bot → API Token.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="tg-chatid">Chat ID (numeric)</label>
|
||||
<input
|
||||
id="tg-chatid"
|
||||
className="form-input"
|
||||
type="text"
|
||||
placeholder="123456789"
|
||||
value={chatId}
|
||||
onChange={e => setChatId(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="form-hint">
|
||||
Message <strong>@userinfobot</strong> to get your numeric ID.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
disabled={testing || saving || !token || !chatId}
|
||||
onClick={handleTest}
|
||||
>
|
||||
{testing
|
||||
? <><div className="spinner" style={{ width: 13, height: 13 }} /> Sending…</>
|
||||
: <><Send size={13} /> Test Alert</>
|
||||
}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? <><div className="spinner" style={{ width: 14, height: 14 }} /> Saving…</> : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
593
frontend/src/index.css
Normal file
593
frontend/src/index.css
Normal file
@@ -0,0 +1,593 @@
|
||||
/* ── Variables ──────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #080c14;
|
||||
--bg-card: #0f1825;
|
||||
--bg-elevated: #162030;
|
||||
--bg-input: #0a1220;
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-strong: rgba(255, 255, 255, 0.13);
|
||||
|
||||
--accent: #006fff;
|
||||
--accent-hover: #0056cc;
|
||||
--accent-dim: rgba(0, 111, 255, 0.12);
|
||||
|
||||
--success: #00c853;
|
||||
--success-dim: rgba(0, 200, 83, 0.12);
|
||||
--danger: #ff4444;
|
||||
--danger-dim: rgba(255, 68, 68, 0.12);
|
||||
--warning: #ffaa00;
|
||||
--warning-dim: rgba(255, 170, 0, 0.12);
|
||||
--muted: #4a5778;
|
||||
|
||||
--text: #f0f4ff;
|
||||
--text-secondary:#7a8ba5;
|
||||
--text-muted: #3d4f6a;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 18px;
|
||||
|
||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.65);
|
||||
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── App shell ──────────────────────────────────────────────────────── */
|
||||
.app { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||
.header {
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 28px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 32px; height: 32px;
|
||||
background: var(--accent);
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
.logo-text span { color: var(--accent); }
|
||||
|
||||
/* Stats pills */
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.stat-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.stat-pill strong {
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
}
|
||||
.stat-pill.active strong { color: var(--accent); }
|
||||
.stat-pill.in-stock strong { color: var(--success); }
|
||||
|
||||
/* Connection dot */
|
||||
.conn-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
transition: background 0.4s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.conn-dot.connected { background: var(--success); }
|
||||
.conn-dot.error { background: var(--danger); }
|
||||
|
||||
.header-spacer { flex: 1; }
|
||||
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────── */
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:active { transform: scale(0.97); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 8px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.btn-secondary:hover { color: var(--text); border-color: rgba(255,255,255,0.22); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--bg-elevated); color: var(--text-secondary); }
|
||||
|
||||
.btn-icon {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: 7px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 0;
|
||||
}
|
||||
.btn-icon:hover { background: var(--bg-elevated); color: var(--text-secondary); }
|
||||
.btn-icon.danger:hover { color: var(--danger); background: var(--danger-dim); }
|
||||
.btn-icon.success:hover { color: var(--success); background: var(--success-dim); }
|
||||
.btn-icon.warning:hover { color: var(--warning); background: var(--warning-dim); }
|
||||
|
||||
/* ── Main content ────────────────────────────────────────────────────── */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 32px 28px 48px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Section header ──────────────────────────────────────────────────── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
/* ── Items grid ──────────────────────────────────────────────────────── */
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── Item card ───────────────────────────────────────────────────────── */
|
||||
.item-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.item-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-card);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.item-card.paused { opacity: 0.65; }
|
||||
|
||||
/* Top accent bar */
|
||||
.card-status-bar {
|
||||
height: 3px;
|
||||
background: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.item-card.in_stock .card-status-bar { background: var(--success); }
|
||||
.item-card.sold_out .card-status-bar { background: var(--danger); }
|
||||
.item-card.unknown .card-status-bar { background: var(--muted); }
|
||||
|
||||
/* Card body */
|
||||
.card-body {
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Thumbnail */
|
||||
.card-thumb {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: contain;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
.card-thumb-placeholder {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Info section */
|
||||
.card-info { flex: 1; min-width: 0; }
|
||||
|
||||
.card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 3px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-url {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.card-url a { color: inherit; text-decoration: none; }
|
||||
.card-url a:hover { color: var(--accent); }
|
||||
|
||||
/* Badges row */
|
||||
.card-badges {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.badge-in-stock { background: var(--success-dim); color: var(--success); border-color: rgba(0,200,83,0.25); }
|
||||
.badge-sold-out { background: var(--danger-dim); color: var(--danger); border-color: rgba(255,68,68,0.25); }
|
||||
.badge-unknown { background: rgba(74,87,104,0.2); color: var(--text-secondary); border-color: rgba(74,87,104,0.3); }
|
||||
.badge-paused { background: var(--warning-dim); color: var(--warning); border-color: rgba(255,170,0,0.25); }
|
||||
.badge-alerted { background: var(--accent-dim); color: var(--accent); border-color: rgba(0,111,255,0.25); }
|
||||
|
||||
/* Meta row */
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.card-meta-item strong {
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Card footer / actions */
|
||||
.card-footer {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
justify-content: flex-end;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100px 24px;
|
||||
text-align: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.empty-icon {
|
||||
width: 72px; height: 72px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 20px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.empty-state h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
max-width: 380px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
padding: 24px;
|
||||
animation: backdrop-in 0.15s ease;
|
||||
}
|
||||
@keyframes backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-modal);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 28px;
|
||||
animation: modal-in 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
@keyframes modal-in {
|
||||
from { opacity: 0; transform: scale(0.94) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.modal-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
width: 30px; height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.modal-close:hover { background: var(--bg-elevated); color: var(--text); }
|
||||
|
||||
/* ── Forms ───────────────────────────────────────────────────────────── */
|
||||
.form-group { margin-bottom: 18px; }
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font);
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.form-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(0, 111, 255, 0.15);
|
||||
}
|
||||
.form-input::placeholder { color: var(--text-muted); }
|
||||
.form-input[type="number"] { font-family: var(--font-mono); }
|
||||
|
||||
.form-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.form-hint a { color: var(--accent); text-decoration: none; }
|
||||
.form-hint a:hover { text-decoration: underline; }
|
||||
|
||||
.form-row { display: flex; gap: 14px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Error inline */
|
||||
.form-error {
|
||||
background: var(--danger-dim);
|
||||
border: 1px solid rgba(255, 68, 68, 0.25);
|
||||
color: var(--danger);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Info box (settings) ─────────────────────────────────────────────── */
|
||||
.info-box {
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid rgba(0, 111, 255, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.info-box strong { color: var(--text); }
|
||||
|
||||
/* Success box */
|
||||
.success-box {
|
||||
background: var(--success-dim);
|
||||
border: 1px solid rgba(0, 200, 83, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--success);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Loading / Error states ──────────────────────────────────────────── */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
height: 200px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px; height: 18px;
|
||||
border: 2px solid var(--border-strong);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.error-banner {
|
||||
background: var(--danger-dim);
|
||||
border: 1px solid rgba(255, 68, 68, 0.22);
|
||||
color: var(--danger);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Tooltip ─────────────────────────────────────────────────────────── */
|
||||
[title] { cursor: help; }
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.header { padding: 0 16px; }
|
||||
.header-stats { display: none; }
|
||||
.main { padding: 20px 16px 40px; }
|
||||
.items-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.modal { padding: 22px 18px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.logo-text { font-size: 15px; }
|
||||
.form-row { flex-direction: column; gap: 0; }
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
20
frontend/src/types/index.ts
Normal file
20
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type StockStatus = 'in_stock' | 'sold_out' | 'unknown';
|
||||
|
||||
export interface WatchedItem {
|
||||
id: number;
|
||||
url: string;
|
||||
name: string | null;
|
||||
thumbnail_url: string | null;
|
||||
check_interval: number;
|
||||
is_active: number;
|
||||
last_status: StockStatus;
|
||||
alert_sent: number;
|
||||
check_count: number;
|
||||
last_checked_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
telegram_bot_token: string;
|
||||
telegram_chat_id: string;
|
||||
}
|
||||
18
frontend/tsconfig.json
Normal file
18
frontend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user