Add server deployment: Apache proxy, static frontend, deploy script
- Static frontend (index.html, login.html, config.js) for Apache serving - JSON-based auth API endpoints (/api/login, /api/auth, /api/logout) - Apache config with ProxyPass for /social-reports path - deploy/setup.sh for Ubuntu + Apache + Docker deployment - docker-compose.prod.yml binds ports to 127.0.0.1 only - Configurable API base URL via frontend/config.js Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ae981e8cb4
commit
c5c40aa4e5
7 changed files with 800 additions and 4 deletions
|
|
@ -168,7 +168,54 @@ const server = createServer(async (req, res) => {
|
|||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
|
||||
// ─── Login routes ───
|
||||
// ─── Auth API (JSON-based, for static frontend) ───
|
||||
|
||||
if (url.pathname === '/api/auth' && req.method === 'GET') {
|
||||
if (isAuthenticated(req)) {
|
||||
sendJSON(res, 200, { ok: true });
|
||||
} else {
|
||||
sendJSON(res, 401, { ok: false, error: 'Not authenticated' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/login' && req.method === 'POST') {
|
||||
const body = await parseBody(req);
|
||||
let username = '', password = '';
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
username = json.username || '';
|
||||
password = json.password || '';
|
||||
} catch {
|
||||
const params = new URLSearchParams(body);
|
||||
username = params.get('username') || '';
|
||||
password = params.get('password') || '';
|
||||
}
|
||||
|
||||
if (username === DASH_USER && password === DASH_PASS) {
|
||||
const payload = JSON.stringify({ user: username, exp: Date.now() + SESSION_MAX_AGE * 1000 });
|
||||
const token = signSession(payload);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': `sl_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE}`,
|
||||
});
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} else {
|
||||
sendJSON(res, 401, { ok: false, error: 'Invalid username or password' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/logout' && req.method === 'GET') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': 'sl_session=; Path=/; HttpOnly; Max-Age=0',
|
||||
});
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Legacy form login (backward compat for standalone Docker mode) ───
|
||||
|
||||
if (url.pathname === '/login' && req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
|
|
@ -186,7 +233,7 @@ const server = createServer(async (req, res) => {
|
|||
const payload = JSON.stringify({ user: username, exp: Date.now() + SESSION_MAX_AGE * 1000 });
|
||||
const token = signSession(payload);
|
||||
res.writeHead(302, {
|
||||
'Set-Cookie': `sl_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_MAX_AGE}`,
|
||||
'Set-Cookie': `sl_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE}`,
|
||||
'Location': '/',
|
||||
});
|
||||
res.end();
|
||||
|
|
@ -208,8 +255,12 @@ const server = createServer(async (req, res) => {
|
|||
|
||||
// ─── Auth gate (everything below requires login) ───
|
||||
if (!isAuthenticated(req)) {
|
||||
res.writeHead(302, { 'Location': '/login' });
|
||||
res.end();
|
||||
if (req.headers.accept?.includes('application/json') || url.pathname.startsWith('/api/')) {
|
||||
sendJSON(res, 401, { error: 'Not authenticated' });
|
||||
} else {
|
||||
res.writeHead(302, { 'Location': '/login' });
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
59
deploy/apache-social-reports.conf
Normal file
59
deploy/apache-social-reports.conf
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# Social Reporting — Apache config
|
||||
# Add this inside your existing VirtualHost for optical-dev.oliver.solutions
|
||||
# or include it via: Include /opt/social-reporting/deploy/apache-social-reports.conf
|
||||
|
||||
# Enable required modules (run once):
|
||||
# sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite
|
||||
|
||||
# ─── Static frontend ───
|
||||
Alias /social-reports /var/www/html/social-reporting
|
||||
<Directory /var/www/html/social-reporting>
|
||||
Options -Indexes
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
|
||||
# SPA fallback — serve index.html for unknown paths
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ /social-reports/index.html [L]
|
||||
</Directory>
|
||||
|
||||
# ─── Proxy API + SSE + dynamic routes to Node backend ───
|
||||
ProxyPreserveHost On
|
||||
ProxyTimeout 600
|
||||
|
||||
# Auth API
|
||||
ProxyPass /social-reports/api/ http://127.0.0.1:3456/api/
|
||||
ProxyPassReverse /social-reports/api/ http://127.0.0.1:3456/api/
|
||||
|
||||
# SSE (long-lived connection — needs no buffering)
|
||||
ProxyPass /social-reports/events http://127.0.0.1:3456/events
|
||||
ProxyPassReverse /social-reports/events http://127.0.0.1:3456/events
|
||||
<Location /social-reports/events>
|
||||
# Disable buffering for SSE
|
||||
SetEnv proxy-sendcl 1
|
||||
SetEnv proxy-nokeepalive 1
|
||||
Header set Cache-Control "no-cache"
|
||||
Header set X-Accel-Buffering "no"
|
||||
</Location>
|
||||
|
||||
# Pipeline run trigger
|
||||
ProxyPass /social-reports/run http://127.0.0.1:3456/run
|
||||
ProxyPassReverse /social-reports/run http://127.0.0.1:3456/run
|
||||
|
||||
# Status check
|
||||
ProxyPass /social-reports/status http://127.0.0.1:3456/status
|
||||
ProxyPassReverse /social-reports/status http://127.0.0.1:3456/status
|
||||
|
||||
# Legacy form login (standalone mode fallback)
|
||||
ProxyPass /social-reports/login http://127.0.0.1:3456/login
|
||||
ProxyPassReverse /social-reports/login http://127.0.0.1:3456/login
|
||||
|
||||
# Legacy logout
|
||||
ProxyPass /social-reports/logout http://127.0.0.1:3456/logout
|
||||
ProxyPassReverse /social-reports/logout http://127.0.0.1:3456/logout
|
||||
|
||||
# Report viewer
|
||||
ProxyPassMatch ^/social-reports/report/(.*)$ http://127.0.0.1:3456/report/$1
|
||||
ProxyPassReverse /social-reports/report/ http://127.0.0.1:3456/report/
|
||||
144
deploy/setup.sh
Executable file
144
deploy/setup.sh
Executable file
|
|
@ -0,0 +1,144 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Social Reporting — Server Deployment Script
|
||||
# Target: Ubuntu + Apache + Docker
|
||||
# URL: https://optical-dev.oliver.solutions/social-reports
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
REPO_URL="${REPO_URL:-}" # Set before running: export REPO_URL="https://x-token-auth:TOKEN@bitbucket.org/zlalani/social-reporting-tool.git"
|
||||
BACKEND_DIR="/opt/social-reporting"
|
||||
FRONTEND_DIR="/var/www/html/social-reporting"
|
||||
APACHE_CONF="/etc/apache2/conf-available/social-reports.conf"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[+]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
err() { echo -e "${RED}[x]${NC} $1"; exit 1; }
|
||||
|
||||
# ─── Pre-checks ───
|
||||
[[ -z "$REPO_URL" ]] && err "REPO_URL not set. Run: export REPO_URL='https://x-token-auth:YOUR_TOKEN@bitbucket.org/zlalani/social-reporting-tool.git'"
|
||||
command -v docker >/dev/null || err "Docker not installed"
|
||||
command -v docker compose >/dev/null 2>&1 || command -v docker-compose >/dev/null || err "Docker Compose not installed"
|
||||
command -v apache2ctl >/dev/null || err "Apache not installed"
|
||||
|
||||
# ─── 1. Clone or pull repo ───
|
||||
if [[ -d "$BACKEND_DIR/.git" ]]; then
|
||||
log "Updating existing repo at $BACKEND_DIR..."
|
||||
cd "$BACKEND_DIR"
|
||||
git remote set-url origin "$REPO_URL"
|
||||
git pull origin main
|
||||
else
|
||||
log "Cloning repo to $BACKEND_DIR..."
|
||||
sudo mkdir -p "$BACKEND_DIR"
|
||||
sudo chown "$(whoami):$(whoami)" "$BACKEND_DIR"
|
||||
git clone "$REPO_URL" "$BACKEND_DIR"
|
||||
fi
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
# ─── 2. Create .env if missing ───
|
||||
if [[ ! -f "$BACKEND_DIR/.env" ]]; then
|
||||
warn ".env file not found — creating template"
|
||||
cat > "$BACKEND_DIR/.env" << 'ENVEOF'
|
||||
APIFY_TOKEN=your_apify_token_here
|
||||
ANTHROPIC_API_KEY=your_anthropic_key_here
|
||||
APIFY_LIVE_APPROVED=true
|
||||
TEST_MODE=false
|
||||
DASHBOARD_PORT=3456
|
||||
DATABASE_URL=postgresql://sl_user:sl_pass@db:5432/social_listening
|
||||
APIFY_COST_LIMIT=5
|
||||
DASH_USER=admin
|
||||
DASH_PASS=changeme
|
||||
SESSION_SECRET=
|
||||
ENVEOF
|
||||
# Generate a random session secret
|
||||
SESSION_SECRET=$(openssl rand -hex 32)
|
||||
sed -i "s/^SESSION_SECRET=$/SESSION_SECRET=${SESSION_SECRET}/" "$BACKEND_DIR/.env"
|
||||
warn "Edit $BACKEND_DIR/.env with your API keys and credentials!"
|
||||
warn " APIFY_TOKEN, ANTHROPIC_API_KEY, DASH_USER, DASH_PASS"
|
||||
fi
|
||||
|
||||
# ─── 3. Deploy frontend ───
|
||||
log "Deploying frontend to $FRONTEND_DIR..."
|
||||
sudo mkdir -p "$FRONTEND_DIR"
|
||||
sudo cp "$BACKEND_DIR/frontend/index.html" "$FRONTEND_DIR/"
|
||||
sudo cp "$BACKEND_DIR/frontend/login.html" "$FRONTEND_DIR/"
|
||||
sudo cp "$BACKEND_DIR/frontend/config.js" "$FRONTEND_DIR/"
|
||||
sudo chown -R www-data:www-data "$FRONTEND_DIR"
|
||||
log "Frontend deployed: index.html, login.html, config.js"
|
||||
|
||||
# ─── 4. Apache config ───
|
||||
log "Setting up Apache config..."
|
||||
sudo cp "$BACKEND_DIR/deploy/apache-social-reports.conf" "$APACHE_CONF"
|
||||
|
||||
# Enable required modules
|
||||
for mod in proxy proxy_http headers rewrite; do
|
||||
if ! apache2ctl -M 2>/dev/null | grep -q "${mod}_module"; then
|
||||
log "Enabling Apache module: $mod"
|
||||
sudo a2enmod "$mod"
|
||||
fi
|
||||
done
|
||||
|
||||
# Enable the config
|
||||
sudo a2enconf social-reports 2>/dev/null || true
|
||||
|
||||
# Test Apache config
|
||||
log "Testing Apache config..."
|
||||
if sudo apache2ctl configtest 2>&1; then
|
||||
log "Apache config OK"
|
||||
else
|
||||
err "Apache config test failed — check $APACHE_CONF"
|
||||
fi
|
||||
|
||||
# ─── 5. Docker Compose ───
|
||||
log "Starting Docker containers..."
|
||||
cd "$BACKEND_DIR"
|
||||
|
||||
# Use the correct docker compose command
|
||||
if command -v "docker compose" >/dev/null 2>&1; then
|
||||
COMPOSE="docker compose"
|
||||
else
|
||||
COMPOSE="docker-compose"
|
||||
fi
|
||||
|
||||
$COMPOSE -f docker-compose.yml -f docker-compose.prod.yml build
|
||||
$COMPOSE -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
# Wait for health
|
||||
log "Waiting for services to be healthy..."
|
||||
sleep 5
|
||||
if curl -sf http://127.0.0.1:3456/status > /dev/null 2>&1; then
|
||||
log "Backend is running on port 3456"
|
||||
else
|
||||
warn "Backend not responding yet — check: $COMPOSE logs social-listening"
|
||||
fi
|
||||
|
||||
# ─── 6. Reload Apache ───
|
||||
log "Reloading Apache..."
|
||||
sudo systemctl reload apache2
|
||||
|
||||
# ─── Done ───
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════"
|
||||
echo -e "${GREEN} Deployment complete!${NC}"
|
||||
echo ""
|
||||
echo " Frontend: https://optical-dev.oliver.solutions/social-reports/"
|
||||
echo " Backend: http://127.0.0.1:3456 (Docker)"
|
||||
echo " Login: https://optical-dev.oliver.solutions/social-reports/login.html"
|
||||
echo ""
|
||||
echo " Backend dir: $BACKEND_DIR"
|
||||
echo " Frontend dir: $FRONTEND_DIR"
|
||||
echo " Apache conf: $APACHE_CONF"
|
||||
echo ""
|
||||
echo " To update later:"
|
||||
echo " cd $BACKEND_DIR && git pull"
|
||||
echo " $COMPOSE -f docker-compose.yml -f docker-compose.prod.yml build && $COMPOSE -f docker-compose.yml -f docker-compose.prod.yml up -d"
|
||||
echo " sudo cp frontend/* $FRONTEND_DIR/ && sudo systemctl reload apache2"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════"
|
||||
13
docker-compose.prod.yml
Normal file
13
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Production overrides — use with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
services:
|
||||
db:
|
||||
ports:
|
||||
- "127.0.0.1:5435:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
social-listening:
|
||||
ports:
|
||||
- "127.0.0.1:3456:3456"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- SESSION_SECRET=${SESSION_SECRET:-}
|
||||
4
frontend/config.js
Normal file
4
frontend/config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// ─── Frontend config (injected before app scripts) ───
|
||||
// API base points to the proxied backend path
|
||||
window.__API_BASE = '/social-reports';
|
||||
window.__SSE_BASE = '/social-reports';
|
||||
456
frontend/index.html
Normal file
456
frontend/index.html
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Social Listening Pipeline</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, sans-serif; background: #0a0a0a; color: #e0e0e0; min-height: 100vh; }
|
||||
.container { max-width: 860px; margin: 0 auto; padding: 40px 24px; }
|
||||
h1 { font-size: 28px; font-weight: 800; margin-bottom: 8px; letter-spacing: -0.5px; }
|
||||
.subtitle { color: #888; margin-bottom: 24px; font-size: 14px; }
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 32px; border-bottom: 1px solid #2a2a2a; }
|
||||
.tab { padding: 10px 20px; font-size: 13px; font-weight: 600; color: #666; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; }
|
||||
.tab:hover { color: #e0e0e0; }
|
||||
.tab.active { color: #f5a623; border-bottom-color: #f5a623; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
.form-section { background: #141414; border: 1px solid #2a2a2a; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
||||
.form-section h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: #f5a623; margin-bottom: 16px; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 12px; font-weight: 600; color: #aaa; margin-bottom: 6px; }
|
||||
.field input, .field select, .field textarea { width: 100%; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 10px 14px; color: #e0e0e0; font-size: 13px; font-family: 'Montserrat', sans-serif; }
|
||||
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: #f5a623; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.checkbox-row { display: flex; gap: 16px; margin-bottom: 16px; }
|
||||
.checkbox-row label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
|
||||
.checkbox-row input[type="checkbox"] { width: auto; accent-color: #f5a623; }
|
||||
.json-upload-row { display: flex; align-items: center; }
|
||||
.upload-btn { display: inline-block; background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; border-radius: 8px; padding: 8px 16px; font-size: 12px; font-weight: 600; cursor: pointer; font-family: 'Montserrat', sans-serif; transition: all 0.2s; }
|
||||
.upload-btn:hover { background: #333; border-color: #f5a623; }
|
||||
button.run { width: 100%; background: #f5a623; color: #000; border: none; border-radius: 8px; padding: 14px; font-size: 15px; font-weight: 700; cursor: pointer; letter-spacing: 0.5px; font-family: 'Montserrat', sans-serif; }
|
||||
button.run:hover { background: #e69920; }
|
||||
button.run:disabled { background: #333; color: #666; cursor: not-allowed; }
|
||||
.cost-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 20px 0; }
|
||||
.cost-card { background: #141414; border: 1px solid #2a2a2a; border-radius: 10px; padding: 16px; text-align: center; }
|
||||
.cost-value { font-size: 22px; font-weight: 800; color: #f5a623; font-variant-numeric: tabular-nums; }
|
||||
.cost-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: #666; margin-top: 4px; }
|
||||
.progress-section { margin-top: 24px; }
|
||||
.stage-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #141414; border: 1px solid #2a2a2a; border-radius: 8px; margin-bottom: 8px; }
|
||||
.stage-dot { width: 10px; height: 10px; border-radius: 50%; background: #333; flex-shrink: 0; }
|
||||
.stage-dot.running { background: #f5a623; animation: pulse 1s infinite; }
|
||||
.stage-dot.done { background: #4caf50; }
|
||||
.stage-dot.error { background: #f44336; }
|
||||
.stage-name { flex: 1; font-size: 13px; font-weight: 500; }
|
||||
.stage-detail { font-size: 11px; color: #888; }
|
||||
.stage-cost { font-size: 11px; color: #f5a623; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 60px; text-align: right; }
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
.log-box { background: #0a0a0a; border: 1px solid #2a2a2a; border-radius: 8px; padding: 16px; margin-top: 16px; max-height: 250px; overflow-y: auto; font-family: 'SF Mono', Monaco, 'Courier New', monospace; font-size: 11px; color: #888; line-height: 1.8; }
|
||||
.history-table { width: 100%; border-collapse: collapse; }
|
||||
.history-table th { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #666; text-align: left; padding: 10px 12px; border-bottom: 1px solid #2a2a2a; }
|
||||
.history-table td { font-size: 13px; padding: 12px; border-bottom: 1px solid #1a1a1a; }
|
||||
.history-table tr:hover td { background: #141414; }
|
||||
.history-table .cost { color: #f5a623; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.status-badge { display: inline-block; font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status-badge.completed { background: #1b3a1b; color: #4caf50; }
|
||||
.status-badge.running { background: #3a2e1b; color: #f5a623; }
|
||||
.status-badge.failed { background: #3a1b1b; color: #f44336; }
|
||||
.expand-btn { background: none; border: 1px solid #333; color: #888; border-radius: 6px; padding: 4px 10px; font-size: 11px; cursor: pointer; font-family: 'Montserrat', sans-serif; }
|
||||
.expand-btn:hover { border-color: #f5a623; color: #f5a623; }
|
||||
.cost-detail-row td { padding: 0; }
|
||||
.cost-detail { background: #0a0a0a; border: 1px solid #1a1a1a; border-radius: 8px; margin: 8px 12px 12px; padding: 16px; }
|
||||
.cost-detail table { width: 100%; }
|
||||
.cost-detail th { font-size: 9px; color: #555; padding: 6px 8px; }
|
||||
.cost-detail td { font-size: 12px; padding: 6px 8px; border-bottom: 1px solid #141414; }
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #555; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div style="display:flex;justify-content:space-between;align-items:start">
|
||||
<div>
|
||||
<h1>Social Listening Pipeline</h1>
|
||||
<p class="subtitle">Automated social media research → client-ready reports</p>
|
||||
</div>
|
||||
<a href="javascript:void(0)" id="logoutBtn" style="font-size:12px;color:#666;text-decoration:none;padding:8px 14px;border:1px solid #333;border-radius:6px;font-family:Montserrat,sans-serif;font-weight:600" onmouseover="this.style.borderColor='#f5a623';this.style.color='#f5a623'" onmouseout="this.style.borderColor='#333';this.style.color='#666'">Sign Out</a>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('pipeline')">Pipeline</div>
|
||||
<div class="tab" onclick="switchTab('history')">Run History</div>
|
||||
</div>
|
||||
|
||||
<!-- PIPELINE TAB -->
|
||||
<div id="tab-pipeline" class="tab-content active">
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Load Brief from JSON</h2>
|
||||
<div class="json-upload-row">
|
||||
<label class="upload-btn" for="jsonFile">Choose JSON File</label>
|
||||
<input type="file" id="jsonFile" accept=".json" style="display:none" onchange="loadJSON(this)">
|
||||
<span id="jsonFileName" style="font-size:12px;color:#888;margin-left:12px">No file selected</span>
|
||||
</div>
|
||||
<div id="jsonPreview" style="display:none;margin-top:12px">
|
||||
<div style="font-size:11px;color:#f5a623;font-weight:600;margin-bottom:6px">LOADED BRIEF</div>
|
||||
<pre id="jsonPreviewText" style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:8px;padding:12px;font-size:11px;color:#888;max-height:120px;overflow-y:auto;white-space:pre-wrap"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Client Brief</h2>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>Client Name</label><input id="clientName" placeholder="H&M"></div>
|
||||
<div class="field"><label>Category</label><input id="category" placeholder="fast fashion"></div>
|
||||
</div>
|
||||
<div class="field"><label>Hashtags (comma-separated)</label><input id="hashtags" placeholder="#hm, #handm, #hmfashion"></div>
|
||||
<div class="field"><label>Keywords (comma-separated)</label><input id="keywords" placeholder="hm haul, hm try on"></div>
|
||||
<h2 style="margin-top:24px">Platforms</h2>
|
||||
<div class="checkbox-row">
|
||||
<label><input type="checkbox" id="p-tiktok" checked> TikTok</label>
|
||||
<label><input type="checkbox" id="p-instagram"> Instagram</label>
|
||||
<label><input type="checkbox" id="p-youtube"> YouTube</label>
|
||||
</div>
|
||||
<h2>Influencers</h2>
|
||||
<div class="field"><label>TikTok handles</label><input id="inf-tiktok" placeholder="@hm, @hmusa"></div>
|
||||
<div class="field"><label>Instagram handles</label><input id="inf-instagram" placeholder="hm, hmusa"></div>
|
||||
<div class="field"><label>YouTube handles</label><input id="inf-youtube" placeholder="@hm"></div>
|
||||
</div>
|
||||
|
||||
<button class="run" id="runBtn" onclick="startPipeline()">Run Pipeline</button>
|
||||
|
||||
<!-- Live cost tracker -->
|
||||
<div id="costSection" style="display:none">
|
||||
<div class="cost-bar" style="grid-template-columns: repeat(5, 1fr);">
|
||||
<div class="cost-card"><div class="cost-value" id="costTotal">$0.00</div><div class="cost-label">Total Cost</div></div>
|
||||
<div class="cost-card"><div class="cost-value" id="costClaude">$0.00</div><div class="cost-label">Claude API</div></div>
|
||||
<div class="cost-card">
|
||||
<div class="cost-value" id="costApify">$0.00</div>
|
||||
<div class="cost-label">Apify</div>
|
||||
<div id="apifyBudgetBar" style="margin-top:6px;display:none">
|
||||
<div style="background:#2a2a2a;border-radius:4px;height:4px;overflow:hidden">
|
||||
<div id="apifyBudgetFill" style="height:100%;background:#f5a623;width:0%;transition:width 0.3s"></div>
|
||||
</div>
|
||||
<div id="apifyBudgetText" style="font-size:9px;color:#666;margin-top:2px">$0 / $5</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cost-card"><div class="cost-value" id="costTokens">0</div><div class="cost-label">Tokens</div></div>
|
||||
<div class="cost-card"><div class="cost-value" id="costBudget" style="font-size:16px">—</div><div class="cost-label">Apify Budget</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-section" id="progressSection" style="display:none">
|
||||
<div id="stages"></div>
|
||||
<div class="log-box" id="logBox"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- HISTORY TAB -->
|
||||
<div id="tab-history" class="tab-content">
|
||||
<div id="historyContent"><div class="empty-state">Loading...</div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script>
|
||||
// ─── API base URL (set by deploy, empty = same origin) ───
|
||||
const API = window.__API_BASE || '';
|
||||
const SSE_BASE = window.__SSE_BASE || '';
|
||||
|
||||
const STAGES = [
|
||||
'Brief Validation', 'Strategy Review', 'Discovery Scrape', 'Data Review',
|
||||
'Enrichment Scrape', 'Pre-Report Review', 'Desk Research', 'Report Generation'
|
||||
];
|
||||
|
||||
let eventSource;
|
||||
let loadedBrief = null;
|
||||
let totalClaude = 0, totalApify = 0, totalTokens = 0;
|
||||
let apifyBudgetLimit = 5;
|
||||
const stageCosts = {};
|
||||
|
||||
// ─── Auth check on load ───
|
||||
(async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch(API + '/api/auth', { credentials: 'include' });
|
||||
if (!res.ok) { window.location.href = './login.html'; }
|
||||
} catch { window.location.href = './login.html'; }
|
||||
})();
|
||||
|
||||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||
await fetch(API + '/api/logout', { credentials: 'include' });
|
||||
window.location.href = './login.html';
|
||||
});
|
||||
|
||||
// ─── Tabs ───
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||
document.querySelector(`.tab-content#tab-${name}`).classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
if (name === 'history') loadHistory();
|
||||
}
|
||||
|
||||
// ─── JSON upload ───
|
||||
function loadJSON(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
document.getElementById('jsonFileName').textContent = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const brief = JSON.parse(e.target.result);
|
||||
loadedBrief = brief;
|
||||
document.getElementById('jsonPreview').style.display = 'block';
|
||||
document.getElementById('jsonPreviewText').textContent = JSON.stringify(brief, null, 2);
|
||||
if (brief.clientName) document.getElementById('clientName').value = brief.clientName;
|
||||
if (brief.category) document.getElementById('category').value = brief.category;
|
||||
if (brief.hashtags) document.getElementById('hashtags').value = brief.hashtags.join(', ');
|
||||
if (brief.keywords) document.getElementById('keywords').value = brief.keywords.join(', ');
|
||||
document.getElementById('p-tiktok').checked = (brief.platforms || []).includes('tiktok');
|
||||
document.getElementById('p-instagram').checked = (brief.platforms || []).includes('instagram');
|
||||
document.getElementById('p-youtube').checked = (brief.platforms || []).includes('youtube');
|
||||
if (brief.influencers) {
|
||||
if (brief.influencers.tiktok) document.getElementById('inf-tiktok').value = brief.influencers.tiktok.join(', ');
|
||||
if (brief.influencers.instagram) document.getElementById('inf-instagram').value = brief.influencers.instagram.join(', ');
|
||||
if (brief.influencers.youtube) document.getElementById('inf-youtube').value = brief.influencers.youtube.join(', ');
|
||||
}
|
||||
} catch (err) { alert('Invalid JSON: ' + err.message); }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
// ─── Cost display ───
|
||||
function updateCosts() {
|
||||
const total = totalClaude + totalApify;
|
||||
document.getElementById('costTotal').textContent = '$' + total.toFixed(4);
|
||||
document.getElementById('costClaude').textContent = '$' + totalClaude.toFixed(4);
|
||||
document.getElementById('costApify').textContent = '$' + totalApify.toFixed(4);
|
||||
document.getElementById('costTokens').textContent = totalTokens.toLocaleString();
|
||||
const pct = Math.min(100, (totalApify / apifyBudgetLimit) * 100);
|
||||
const budgetBar = document.getElementById('apifyBudgetBar');
|
||||
if (budgetBar) budgetBar.style.display = 'block';
|
||||
const fill = document.getElementById('apifyBudgetFill');
|
||||
if (fill) {
|
||||
fill.style.width = pct + '%';
|
||||
fill.style.background = pct >= 100 ? '#f44336' : pct >= 80 ? '#ff9800' : '#f5a623';
|
||||
}
|
||||
const budgetText = document.getElementById('apifyBudgetText');
|
||||
if (budgetText) budgetText.textContent = '$' + totalApify.toFixed(2) + ' / $' + apifyBudgetLimit.toFixed(2);
|
||||
const budgetCard = document.getElementById('costBudget');
|
||||
if (budgetCard) {
|
||||
const remaining = Math.max(0, apifyBudgetLimit - totalApify);
|
||||
budgetCard.textContent = '$' + remaining.toFixed(2);
|
||||
budgetCard.style.color = pct >= 100 ? '#f44336' : pct >= 80 ? '#ff9800' : '#4caf50';
|
||||
}
|
||||
for (const [stage, cost] of Object.entries(stageCosts)) {
|
||||
const el = document.getElementById(`stagecost-${stage}`);
|
||||
if (el) el.textContent = '$' + cost.toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pipeline ───
|
||||
function log(msg) {
|
||||
const box = document.getElementById('logBox');
|
||||
box.textContent += msg + '\n';
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function renderStages() {
|
||||
document.getElementById('stages').innerHTML = STAGES.map((name, i) =>
|
||||
`<div class="stage-row" id="stage-${i+1}">
|
||||
<div class="stage-dot" id="dot-${i+1}"></div>
|
||||
<div class="stage-name">Stage ${i+1}: ${name}</div>
|
||||
<div class="stage-cost" id="stagecost-${i+1}"></div>
|
||||
<div class="stage-detail" id="detail-${i+1}"></div>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function startPipeline() {
|
||||
const btn = document.getElementById('runBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Running...';
|
||||
document.getElementById('progressSection').style.display = 'block';
|
||||
document.getElementById('costSection').style.display = 'block';
|
||||
totalClaude = 0; totalApify = 0; totalTokens = 0;
|
||||
Object.keys(stageCosts).forEach(k => delete stageCosts[k]);
|
||||
updateCosts();
|
||||
renderStages();
|
||||
|
||||
const platforms = [];
|
||||
if (document.getElementById('p-tiktok').checked) platforms.push('tiktok');
|
||||
if (document.getElementById('p-instagram').checked) platforms.push('instagram');
|
||||
if (document.getElementById('p-youtube').checked) platforms.push('youtube');
|
||||
|
||||
const splitVal = (id) => document.getElementById(id).value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const now = new Date();
|
||||
const ago = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const brief = {
|
||||
clientName: document.getElementById('clientName').value,
|
||||
category: document.getElementById('category').value,
|
||||
hashtags: splitVal('hashtags'),
|
||||
keywords: splitVal('keywords'),
|
||||
platforms,
|
||||
influencers: {
|
||||
tiktok: splitVal('inf-tiktok'),
|
||||
instagram: splitVal('inf-instagram'),
|
||||
youtube: splitVal('inf-youtube'),
|
||||
},
|
||||
dateRange: (loadedBrief && loadedBrief.dateRange)
|
||||
? loadedBrief.dateRange
|
||||
: { from: ago.toISOString(), to: now.toISOString() },
|
||||
};
|
||||
|
||||
const sseUrl = (SSE_BASE || API) + '/events';
|
||||
eventSource = new EventSource(sseUrl, { withCredentials: true });
|
||||
log('Connecting to server...');
|
||||
|
||||
eventSource.addEventListener('connected', (e) => {
|
||||
try { const d = JSON.parse(e.data); if (d.apifyBudgetLimit) apifyBudgetLimit = d.apifyBudgetLimit; updateCosts(); } catch {}
|
||||
log('Connected. Starting pipeline...');
|
||||
fetch(API + '/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(brief),
|
||||
}).catch(err => log('Failed to start: ' + err.message));
|
||||
});
|
||||
|
||||
eventSource.addEventListener('progress', (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
const dot = document.getElementById(`dot-${d.stage}`);
|
||||
const detail = document.getElementById(`detail-${d.stage}`);
|
||||
if (d.status === 'start') { dot.className = 'stage-dot running'; }
|
||||
if (d.status === 'done') { dot.className = 'stage-dot done'; if (detail) detail.textContent = d.detail || ''; }
|
||||
if (d.status === 'error') { dot.className = 'stage-dot error'; if (detail) detail.textContent = d.detail || ''; }
|
||||
log(`[Stage ${d.stage}] ${d.name} — ${d.status}${d.detail ? ': ' + d.detail : ''}`);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('cost', (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.source === 'claude') {
|
||||
totalClaude += d.costUsd;
|
||||
totalTokens += (d.inputTokens || 0) + (d.outputTokens || 0);
|
||||
} else {
|
||||
totalApify += d.costUsd;
|
||||
}
|
||||
stageCosts[d.stage] = (stageCosts[d.stage] || 0) + d.costUsd;
|
||||
updateCosts();
|
||||
log(` [$] ${d.source}: $${d.costUsd.toFixed(4)} — ${d.label}`);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('complete', (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
log(`\nPipeline complete! ${d.trends} trends, ${d.insights} insights, ${d.opportunities} opportunities`);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Run Pipeline';
|
||||
eventSource.close();
|
||||
if (d.reportUrl) {
|
||||
const reportDiv = document.createElement('div');
|
||||
reportDiv.style.cssText = 'text-align:center;margin-top:20px';
|
||||
reportDiv.innerHTML = `<a href="${API}${d.reportUrl}" target="_blank" style="display:inline-block;background:#f5a623;color:#000;padding:14px 32px;border-radius:8px;font-size:15px;font-weight:700;text-decoration:none;font-family:Montserrat,sans-serif;letter-spacing:0.5px">View Report</a>`;
|
||||
document.getElementById('progressSection').appendChild(reportDiv);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', (e) => {
|
||||
if (e.data) {
|
||||
const d = JSON.parse(e.data);
|
||||
log(`ERROR: ${d.message}`);
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Run Pipeline';
|
||||
});
|
||||
}
|
||||
|
||||
// ─── History ───
|
||||
async function loadHistory() {
|
||||
const el = document.getElementById('historyContent');
|
||||
try {
|
||||
const res = await fetch(API + '/api/runs', { credentials: 'include' });
|
||||
const runs = await res.json();
|
||||
|
||||
if (!runs.length) {
|
||||
el.innerHTML = '<div class="empty-state">No runs yet. Start a pipeline to see history here.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<table class="history-table">
|
||||
<thead><tr>
|
||||
<th>Client</th><th>Category</th><th>Status</th>
|
||||
<th>Claude</th><th>Apify</th><th>Total</th>
|
||||
<th>Tokens</th><th>Date</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>${runs.map(r => `
|
||||
<tr>
|
||||
<td style="font-weight:600">${esc(r.client_name)}</td>
|
||||
<td style="color:#888">${esc(r.category)}</td>
|
||||
<td><span class="status-badge ${r.status}">${r.status}</span></td>
|
||||
<td class="cost">$${Number(r.claude_cost_usd).toFixed(4)}</td>
|
||||
<td class="cost">$${Number(r.apify_cost_usd).toFixed(4)}</td>
|
||||
<td class="cost" style="color:#fff">$${Number(r.total_cost_usd).toFixed(4)}</td>
|
||||
<td style="color:#888;font-size:12px">${(Number(r.total_input_tokens) + Number(r.total_output_tokens)).toLocaleString()}</td>
|
||||
<td style="color:#666;font-size:11px">${new Date(r.started_at).toLocaleDateString()} ${new Date(r.started_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}</td>
|
||||
<td>${r.report_path ? `<a href="${API}/report/${r.id}" target="_blank" class="expand-btn" style="text-decoration:none;margin-right:6px">Report</a>` : ''}<button class="expand-btn" onclick="toggleCostDetail(${r.id}, this)">Details</button></td>
|
||||
</tr>
|
||||
<tr class="cost-detail-row" id="detail-row-${r.id}" style="display:none">
|
||||
<td colspan="9"><div class="cost-detail" id="cost-detail-${r.id}">Loading...</div></td>
|
||||
</tr>
|
||||
`).join('')}</tbody>
|
||||
</table>`;
|
||||
} catch (err) {
|
||||
el.innerHTML = `<div class="empty-state">Failed to load history: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCostDetail(runId, btn) {
|
||||
const row = document.getElementById(`detail-row-${runId}`);
|
||||
if (row.style.display !== 'none') {
|
||||
row.style.display = 'none';
|
||||
btn.textContent = 'Details';
|
||||
return;
|
||||
}
|
||||
row.style.display = '';
|
||||
btn.textContent = 'Hide';
|
||||
|
||||
const el = document.getElementById(`cost-detail-${runId}`);
|
||||
try {
|
||||
const res = await fetch(API + `/api/runs/${runId}/costs`, { credentials: 'include' });
|
||||
const costs = await res.json();
|
||||
|
||||
if (!costs.length) {
|
||||
el.innerHTML = '<div style="color:#555;font-size:12px">No cost data recorded for this run.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Stage</th><th>Source</th><th>Label</th>
|
||||
<th>Input Tokens</th><th>Output Tokens</th><th>Cost</th>
|
||||
</tr></thead>
|
||||
<tbody>${costs.map(c => `
|
||||
<tr>
|
||||
<td style="color:#888">S${c.stage}</td>
|
||||
<td><span style="color:${c.source === 'claude' ? '#a78bfa' : '#60a5fa'};font-weight:600;font-size:11px">${c.source.toUpperCase()}</span></td>
|
||||
<td style="font-size:11px">${esc(c.label)}</td>
|
||||
<td style="color:#888;font-size:11px">${c.input_tokens.toLocaleString()}</td>
|
||||
<td style="color:#888;font-size:11px">${c.output_tokens.toLocaleString()}</td>
|
||||
<td class="cost">$${Number(c.cost_usd).toFixed(4)}</td>
|
||||
</tr>
|
||||
`).join('')}</tbody>
|
||||
</table>`;
|
||||
} catch (err) {
|
||||
el.innerHTML = `<div style="color:#f44336;font-size:12px">Error: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
69
frontend/login.html
Normal file
69
frontend/login.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login — Social Listening</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Montserrat', sans-serif; background: #0a0a0a; color: #e0e0e0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
.login-box { background: #141414; border: 1px solid #2a2a2a; border-radius: 16px; padding: 40px; width: 100%; max-width: 380px; }
|
||||
.login-box h1 { font-size: 22px; font-weight: 800; margin-bottom: 6px; letter-spacing: -0.3px; }
|
||||
.login-box .sub { font-size: 13px; color: #666; margin-bottom: 28px; }
|
||||
.field { margin-bottom: 18px; }
|
||||
.field label { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #888; margin-bottom: 6px; }
|
||||
.field input { width: 100%; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 12px 14px; color: #e0e0e0; font-size: 14px; font-family: 'Montserrat', sans-serif; }
|
||||
.field input:focus { outline: none; border-color: #f5a623; }
|
||||
.error { background: #3a1b1b; color: #f44336; border: 1px solid #5a2020; border-radius: 8px; padding: 10px 14px; font-size: 12px; font-weight: 600; margin-bottom: 18px; display: none; }
|
||||
button { width: 100%; background: #f5a623; color: #000; border: none; border-radius: 8px; padding: 14px; font-size: 15px; font-weight: 700; cursor: pointer; font-family: 'Montserrat', sans-serif; letter-spacing: 0.5px; }
|
||||
button:hover { background: #e69920; }
|
||||
button:disabled { background: #333; color: #666; cursor: not-allowed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<h1>Social Listening</h1>
|
||||
<div class="sub">Sign in to access the dashboard</div>
|
||||
<div class="error" id="errorMsg"></div>
|
||||
<form id="loginForm">
|
||||
<div class="field"><label>Username</label><input name="username" id="username" type="text" autocomplete="username" required autofocus></div>
|
||||
<div class="field"><label>Password</label><input name="password" id="password" type="password" autocomplete="current-password" required></div>
|
||||
<button type="submit" id="submitBtn">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="config.js"></script>
|
||||
<script>
|
||||
const API = window.__API_BASE || '';
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submitBtn');
|
||||
const err = document.getElementById('errorMsg');
|
||||
btn.disabled = true; btn.textContent = 'Signing in...';
|
||||
err.style.display = 'none';
|
||||
try {
|
||||
const res = await fetch(API + '/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
window.location.href = './';
|
||||
} else {
|
||||
err.textContent = data.error || 'Invalid username or password';
|
||||
err.style.display = 'block';
|
||||
}
|
||||
} catch (ex) {
|
||||
err.textContent = 'Connection failed: ' + ex.message;
|
||||
err.style.display = 'block';
|
||||
}
|
||||
btn.disabled = false; btn.textContent = 'Sign In';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue