notebooklm-api/public/index.html

1217 lines
50 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NotebookLM Scheduler</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f4f5f7;
--surface:#fff;
--surface2:#f8f9fb;
--border:#e4e7ed;
--border2:#d1d5e0;
--primary:#5a57e8;
--primary-bg:#eeedfd;
--primary-hover:#4845d4;
--text:#1a1d27;
--text2:#5c6480;
--text3:#9399b0;
--green:#16a34a;
--green-bg:#dcfce7;
--red:#dc2626;
--red-bg:#fee2e2;
--amber:#d97706;
--amber-bg:#fef3c7;
--shadow:0 1px 3px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);
--shadow-md:0 4px 12px rgba(0,0,0,.08),0 2px 4px rgba(0,0,0,.04);
--shadow-lg:0 12px 32px rgba(0,0,0,.12),0 4px 8px rgba(0,0,0,.06);
--radius:10px;
--radius-sm:6px;
--radius-lg:14px;
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
--transition:120ms ease;
}
body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}
/* ── Header ── */
.header{
background:var(--surface);
border-bottom:1px solid var(--border);
display:flex;align-items:center;gap:0;
padding:0 24px;height:56px;
position:sticky;top:0;z-index:100;
}
.logo{display:flex;align-items:center;gap:10px;margin-right:32px;text-decoration:none}
.logo-icon{
width:30px;height:30px;border-radius:8px;
background:var(--primary);display:flex;align-items:center;justify-content:center;
}
.logo-icon svg{width:16px;height:16px;fill:#fff}
.logo-text{font-weight:700;font-size:15px;color:var(--text)}
.nav{display:flex;gap:2px;flex:1}
.nav-btn{
padding:6px 14px;border-radius:var(--radius-sm);border:none;
background:none;cursor:pointer;font-size:13px;font-weight:500;
color:var(--text2);transition:background var(--transition),color var(--transition);
display:flex;align-items:center;gap:6px;
}
.nav-btn:hover{background:var(--bg);color:var(--text)}
.nav-btn.active{background:var(--primary-bg);color:var(--primary)}
.nav-btn .badge{
background:var(--border2);color:var(--text2);
font-size:11px;font-weight:600;padding:1px 6px;border-radius:20px;
min-width:20px;text-align:center;
}
.nav-btn.active .badge{background:var(--primary);color:#fff}
.header-right{margin-left:auto;display:flex;align-items:center;gap:8px}
.auth-dot{
width:8px;height:8px;border-radius:50%;
background:var(--text3);flex-shrink:0;
}
.auth-dot.ok{background:var(--green)}
.auth-label{font-size:12px;color:var(--text3)}
/* ── Layout ── */
.main{padding:24px;max-width:1200px;margin:0 auto}
.tab-content{display:none}
.tab-content.active{display:block}
/* ── Two-column layout ── */
.cols{display:grid;grid-template-columns:280px 1fr;gap:20px;align-items:start}
/* ── Cards ── */
.card{
background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;
}
.card-header{
padding:14px 16px;display:flex;align-items:center;justify-content:space-between;
border-bottom:1px solid var(--border);
}
.card-title{font-size:13px;font-weight:600;color:var(--text)}
.card-sub{font-size:12px;color:var(--text3)}
/* ── Notebook list ── */
.nb-list{list-style:none}
.nb-item{
display:flex;align-items:center;gap:10px;
padding:10px 16px;cursor:pointer;
border-bottom:1px solid var(--border);
transition:background var(--transition);
position:relative;
}
.nb-item:last-child{border-bottom:none}
.nb-item:hover{background:var(--surface2)}
.nb-item.selected{background:var(--primary-bg)}
.nb-item.selected::before{
content:'';position:absolute;left:0;top:8px;bottom:8px;
width:3px;border-radius:0 3px 3px 0;background:var(--primary);
}
.nb-icon{
width:32px;height:32px;border-radius:8px;
background:var(--surface2);border:1px solid var(--border);
display:flex;align-items:center;justify-content:center;flex-shrink:0;
}
.nb-icon svg{width:15px;height:15px;stroke:var(--text2)}
.nb-item.selected .nb-icon{background:var(--primary-bg);border-color:var(--primary)}
.nb-item.selected .nb-icon svg{stroke:var(--primary)}
.nb-info{flex:1;min-width:0}
.nb-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.nb-item.selected .nb-name{color:var(--primary)}
.nb-count{font-size:11px;color:var(--text3);white-space:nowrap}
.nb-src-badge{
font-size:11px;font-weight:600;color:var(--text3);
background:var(--border);padding:2px 7px;border-radius:20px;
flex-shrink:0;
}
.nb-item.selected .nb-src-badge{background:var(--primary);color:#fff}
/* ── Right panel ── */
.panel-empty{
display:flex;flex-direction:column;align-items:center;justify-content:center;
padding:60px 24px;gap:12px;text-align:center;
}
.panel-empty svg{width:40px;height:40px;stroke:var(--text3);opacity:.5}
.panel-empty p{color:var(--text3);font-size:13px}
/* ── Sources list ── */
.src-list{list-style:none}
.src-item{
display:flex;align-items:center;gap:12px;
padding:11px 16px;border-bottom:1px solid var(--border);
}
.src-item:last-child{border-bottom:none}
.src-type-badge{
font-size:10px;font-weight:700;letter-spacing:.3px;
padding:2px 7px;border-radius:4px;flex-shrink:0;text-transform:uppercase;
}
.src-type-pdf{background:#fee2e2;color:#dc2626}
.src-type-url{background:#dbeafe;color:#2563eb}
.src-type-text{background:#dcfce7;color:#16a34a}
.src-type-audio,.src-type-mp3{background:#fce7f3;color:#db2777}
.src-type-video,.src-type-mp4{background:#f3e8ff;color:#9333ea}
.src-type-other{background:var(--border);color:var(--text2)}
.src-name{font-size:13px;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.src-loading{display:flex;align-items:center;gap:4px;color:var(--amber);font-size:12px;flex-shrink:0}
/* ── Cron Jobs ── */
.job-list{display:flex;flex-direction:column;gap:12px}
.job-card{
background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius);padding:16px;
display:grid;grid-template-columns:1fr auto;gap:12px;
box-shadow:var(--shadow);
}
.job-notebook{
font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;
color:var(--primary);margin-bottom:4px;
}
.job-label{font-size:13px;font-weight:600;color:var(--text);margin-bottom:2px}
.job-message{
font-size:13px;color:var(--text2);
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;
margin-bottom:8px;
}
.job-meta{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.job-meta-item{
display:flex;align-items:center;gap:4px;
font-size:12px;color:var(--text3);
}
.job-meta-item svg{width:13px;height:13px;stroke:currentColor;flex-shrink:0}
.job-actions{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
.job-last-run{font-size:11px;color:var(--text3);text-align:right}
.job-last-status{font-size:11px;font-weight:600}
.job-last-status.ok{color:var(--green)}
.job-last-status.err{color:var(--red)}
.job-btns{display:flex;gap:6px;align-items:center}
/* ── Toggle switch ── */
.toggle{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0}
.toggle input{opacity:0;width:0;height:0}
.toggle-track{
position:absolute;inset:0;border-radius:20px;
background:var(--border2);cursor:pointer;
transition:background .2s;
}
.toggle input:checked + .toggle-track{background:var(--primary)}
.toggle-track::after{
content:'';position:absolute;
width:14px;height:14px;border-radius:50%;
background:#fff;top:3px;left:3px;
transition:transform .2s;
box-shadow:0 1px 3px rgba(0,0,0,.2);
}
.toggle input:checked + .toggle-track::after{transform:translateX(16px)}
/* ── Buttons ── */
.btn{
display:inline-flex;align-items:center;gap:6px;
padding:6px 14px;border-radius:var(--radius-sm);border:none;
font-size:13px;font-weight:500;cursor:pointer;
transition:background var(--transition),opacity var(--transition);
white-space:nowrap;text-decoration:none;
}
.btn:disabled{opacity:.5;cursor:not-allowed}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover:not(:disabled){background:var(--primary-hover)}
.btn-ghost{background:none;color:var(--text2);border:1px solid var(--border)}
.btn-ghost:hover:not(:disabled){background:var(--surface2);border-color:var(--border2)}
.btn-danger{background:var(--red-bg);color:var(--red);border:1px solid #fca5a5}
.btn-danger:hover:not(:disabled){background:#fecaca}
.btn-sm{padding:4px 10px;font-size:12px}
.btn-icon{
width:28px;height:28px;padding:0;display:inline-flex;align-items:center;justify-content:center;
background:none;border:1px solid var(--border);border-radius:var(--radius-sm);
cursor:pointer;color:var(--text2);transition:all var(--transition);
}
.btn-icon:hover{background:var(--surface2);border-color:var(--border2);color:var(--text)}
.btn-icon.danger:hover{background:var(--red-bg);border-color:#fca5a5;color:var(--red)}
.btn-icon svg{width:14px;height:14px;stroke:currentColor}
/* ── History table ── */
.history-filters{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--border)}
.filter-select{
padding:5px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);
background:var(--surface);font-size:12px;color:var(--text);cursor:pointer;
appearance:none;padding-right:24px;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239399b0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 6px center;
}
.history-table{width:100%;border-collapse:collapse}
.history-table th{
text-align:left;padding:10px 14px;
font-size:11px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;
color:var(--text3);border-bottom:1px solid var(--border);
background:var(--surface2);
}
.history-table td{
padding:11px 14px;font-size:13px;border-bottom:1px solid var(--border);
vertical-align:top;
}
.history-table tr:last-child td{border-bottom:none}
.history-table tr.expandable{cursor:pointer}
.history-table tr.expandable:hover td{background:var(--surface2)}
.history-table tr.expanded td{background:var(--surface2)}
.history-answer{
grid-column:1/-1;padding:12px 14px;
background:var(--surface2);border-top:1px solid var(--border);
}
.history-answer-text{
font-size:13px;color:var(--text2);white-space:pre-wrap;
max-height:300px;overflow-y:auto;
padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);
line-height:1.6;
}
.status-badge{
display:inline-flex;align-items:center;gap:4px;
font-size:11px;font-weight:600;padding:2px 8px;border-radius:20px;
}
.status-ok{background:var(--green-bg);color:var(--green)}
.status-err{background:var(--red-bg);color:var(--red)}
.webhook-ok{color:var(--green)}
.webhook-err{color:var(--red)}
.webhook-none{color:var(--text3)}
/* ── Modal ── */
.modal-overlay{
position:fixed;inset:0;background:rgba(0,0,0,.4);
z-index:200;display:none;align-items:center;justify-content:center;
padding:20px;backdrop-filter:blur(2px);
}
.modal-overlay.open{display:flex}
.modal{
background:var(--surface);border-radius:var(--radius-lg);
width:100%;max-width:520px;max-height:90vh;overflow-y:auto;
box-shadow:var(--shadow-lg);
}
.modal-header{
padding:20px 24px 16px;display:flex;align-items:center;justify-content:space-between;
border-bottom:1px solid var(--border);position:sticky;top:0;
background:var(--surface);z-index:1;
}
.modal-title{font-size:16px;font-weight:700}
.modal-close{
width:28px;height:28px;border-radius:50%;border:none;
background:var(--surface2);cursor:pointer;display:flex;align-items:center;justify-content:center;
color:var(--text2);transition:background var(--transition);
}
.modal-close:hover{background:var(--border)}
.modal-close svg{width:14px;height:14px;stroke:currentColor}
.modal-body{padding:20px 24px}
.modal-footer{
padding:16px 24px;border-top:1px solid var(--border);
display:flex;justify-content:flex-end;gap:8px;
position:sticky;bottom:0;background:var(--surface);
}
/* ── Form elements ── */
.form-group{margin-bottom:16px}
.form-label{display:block;font-size:12px;font-weight:600;color:var(--text2);margin-bottom:6px}
.form-label .optional{font-weight:400;color:var(--text3)}
.form-input,.form-select,.form-textarea{
width:100%;padding:8px 12px;
border:1px solid var(--border2);border-radius:var(--radius-sm);
background:var(--surface);font-size:13px;color:var(--text);
font-family:var(--font);
transition:border-color var(--transition),box-shadow var(--transition);
outline:none;
}
.form-input:focus,.form-select:focus,.form-textarea:focus{
border-color:var(--primary);
box-shadow:0 0 0 3px rgba(90,87,232,.1);
}
.form-textarea{resize:vertical;min-height:80px;line-height:1.5}
.form-hint{font-size:11px;color:var(--text3);margin-top:4px}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.form-select{appearance:none;padding-right:28px;cursor:pointer;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239399b0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 8px center;
}
.days-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px}
.day-check{display:none}
.day-label{
display:flex;align-items:center;justify-content:center;
height:32px;border-radius:var(--radius-sm);border:1px solid var(--border2);
font-size:12px;font-weight:600;cursor:pointer;
color:var(--text2);transition:all var(--transition);user-select:none;
}
.day-check:checked + .day-label{background:var(--primary);border-color:var(--primary);color:#fff}
.days-row{display:flex;gap:6px;flex-wrap:wrap}
.days-row .day-label{min-width:36px;height:28px;border-radius:var(--radius-sm);font-size:11px}
/* ── Divider ── */
.divider{height:1px;background:var(--border);margin:16px 0}
/* ── Toast ── */
.toast{
position:fixed;bottom:24px;right:24px;z-index:300;
display:flex;flex-direction:column;gap:8px;
}
.toast-item{
background:var(--text);color:#fff;
padding:10px 16px;border-radius:var(--radius);
font-size:13px;box-shadow:var(--shadow-md);
display:flex;align-items:center;gap:10px;
animation:slideIn .2s ease;
min-width:240px;max-width:360px;
}
.toast-item.success{background:#166534}
.toast-item.error{background:#991b1b}
@keyframes slideIn{from{transform:translateY(10px);opacity:0}to{transform:translateY(0);opacity:1}}
/* ── Spinner ── */
.spinner{
width:16px;height:16px;border:2px solid var(--border);
border-top-color:var(--primary);border-radius:50%;
animation:spin .6s linear infinite;display:inline-block;
}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── Section header ── */
.section-hd{
display:flex;align-items:center;justify-content:space-between;
margin-bottom:14px;
}
.section-title{font-size:15px;font-weight:700}
.section-sub{font-size:12px;color:var(--text3);margin-left:6px}
/* ── Loading state ── */
.loading-rows{display:flex;flex-direction:column;gap:8px;padding:16px}
.skeleton{background:linear-gradient(90deg,var(--border) 25%,var(--surface2) 50%,var(--border) 75%);
background-size:200%;animation:shimmer 1.2s infinite;border-radius:6px;height:14px;}
@keyframes shimmer{0%{background-position:200%}100%{background-position:-200%}}
/* ── Misc ── */
.text-muted{color:var(--text3)}
.flex{display:flex}.items-center{align-items:center}.gap-8{gap:8px}.gap-6{gap:6px}
.mt-12{margin-top:12px}.mb-4{margin-bottom:4px}
.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
</style>
</head>
<body>
<!-- Header -->
<header class="header">
<a class="logo" href="/">
<div class="logo-icon">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
</div>
<span class="logo-text">NotebookLM</span>
</a>
<nav class="nav">
<button class="nav-btn active" data-tab="notebooks" onclick="switchTab('notebooks')">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
Notebooks
<span class="badge" id="badge-notebooks"></span>
</button>
<button class="nav-btn" data-tab="cron" onclick="switchTab('cron')">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Lịch chạy
<span class="badge" id="badge-cron"></span>
</button>
<button class="nav-btn" data-tab="history" onclick="switchTab('history')">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 3h18v18H3z" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
Lịch sử
<span class="badge" id="badge-history"></span>
</button>
</nav>
<div class="header-right">
<span class="auth-dot" id="auth-dot"></span>
<span class="auth-label" id="auth-label">Đang kết nối...</span>
</div>
</header>
<!-- Main content -->
<main class="main">
<!-- Tab: Notebooks -->
<div class="tab-content active" id="tab-notebooks">
<div class="cols">
<!-- Left: Notebook list -->
<div class="card">
<div class="card-header">
<span class="card-title">Notebooks</span>
<button class="btn btn-ghost btn-sm" onclick="loadNotebooks()" title="Làm mới">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
Làm mới
</button>
</div>
<ul class="nb-list" id="nb-list">
<div class="loading-rows">
<div class="skeleton" style="width:80%"></div>
<div class="skeleton" style="width:65%"></div>
<div class="skeleton" style="width:75%"></div>
</div>
</ul>
</div>
<!-- Right: Sources panel -->
<div id="nb-panel">
<div class="card">
<div class="panel-empty">
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
<p>Chọn một notebook để xem sources</p>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Cron Jobs -->
<div class="tab-content" id="tab-cron">
<div class="section-hd">
<div>
<span class="section-title">Lịch chạy tự động</span>
<span class="section-sub" id="cron-count"></span>
</div>
<button class="btn btn-primary" onclick="openJobModal()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Tạo lịch mới
</button>
</div>
<div class="job-list" id="job-list">
<div class="loading-rows">
<div class="skeleton"></div><div class="skeleton" style="width:80%"></div>
</div>
</div>
</div>
<!-- Tab: History -->
<div class="tab-content" id="tab-history">
<div class="section-hd">
<div>
<span class="section-title">Lịch sử chạy</span>
<span class="section-sub" id="history-count"></span>
</div>
<button class="btn btn-ghost btn-sm" onclick="loadHistory()">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
Làm mới
</button>
</div>
<div class="card" style="overflow:hidden">
<div class="history-filters">
<select class="filter-select" id="history-nb-filter" onchange="loadHistory()">
<option value="">Tất cả notebook</option>
</select>
<select class="filter-select" id="history-status-filter" onchange="renderHistory()">
<option value="">Tất cả trạng thái</option>
<option value="success">Thành công</option>
<option value="error">Lỗi</option>
</select>
</div>
<div style="overflow-x:auto">
<table class="history-table" id="history-table">
<thead>
<tr>
<th style="width:140px">Thời gian</th>
<th>Notebook</th>
<th>Câu hỏi</th>
<th style="width:90px">Trạng thái</th>
<th style="width:80px">Webhook</th>
</tr>
</thead>
<tbody id="history-body">
<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--text3)">Đang tải...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</main>
<!-- Modal: Create/Edit Job -->
<div class="modal-overlay" id="modal" onclick="closeJobModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<span class="modal-title" id="modal-title">Tạo lịch chạy mới</span>
<button class="modal-close" onclick="closeJobModal()">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Notebook</label>
<select class="form-select" id="f-notebook" onchange="onNotebookSelectChange()">
<option value="">— Chọn notebook —</option>
</select>
<div class="form-hint">Chỉ chọn notebook do bạn sở hữu</div>
</div>
<div class="form-group">
<label class="form-label">Nhãn <span class="optional">(tuỳ chọn)</span></label>
<input class="form-input" id="f-label" type="text" placeholder="Ví dụ: Tóm tắt buổi sáng">
</div>
<div class="form-group">
<label class="form-label">Câu hỏi / Tin nhắn gửi đến AI</label>
<textarea class="form-textarea" id="f-message" placeholder="Ví dụ: Tóm tắt các nguồn trong notebook này thành 5 điểm chính" style="min-height:90px"></textarea>
</div>
<div class="divider"></div>
<div class="form-group">
<label class="form-label">Lịch chạy</label>
<div class="form-row" style="margin-bottom:10px">
<div>
<label class="form-label" style="margin-bottom:4px">Giờ</label>
<select class="form-select" id="f-hour"></select>
</div>
<div>
<label class="form-label" style="margin-bottom:4px">Phút</label>
<select class="form-select" id="f-minute"></select>
</div>
</div>
<div>
<label class="form-label" style="margin-bottom:6px">Lặp lại</label>
<select class="form-select" id="f-repeat" onchange="onRepeatChange()">
<option value="daily">Hàng ngày</option>
<option value="weekdays">Các ngày trong tuần (T2T6)</option>
<option value="weekends">Cuối tuần (T7CN)</option>
<option value="custom">Tuỳ chọn ngày...</option>
</select>
</div>
<div id="f-custom-days" style="margin-top:10px;display:none">
<label class="form-label" style="margin-bottom:6px">Chọn ngày</label>
<div class="days-grid">
<div><input class="day-check" type="checkbox" id="d1" value="1"><label class="day-label" for="d1">T2</label></div>
<div><input class="day-check" type="checkbox" id="d2" value="2"><label class="day-label" for="d2">T3</label></div>
<div><input class="day-check" type="checkbox" id="d3" value="3"><label class="day-label" for="d3">T4</label></div>
<div><input class="day-check" type="checkbox" id="d4" value="4"><label class="day-label" for="d4">T5</label></div>
<div><input class="day-check" type="checkbox" id="d5" value="5"><label class="day-label" for="d5">T6</label></div>
<div><input class="day-check" type="checkbox" id="d6" value="6"><label class="day-label" for="d6">T7</label></div>
<div><input class="day-check" type="checkbox" id="d0" value="0"><label class="day-label" for="d0">CN</label></div>
</div>
</div>
<div class="form-hint mt-12" id="f-cron-preview"></div>
</div>
<div class="divider"></div>
<div class="form-group">
<label class="form-label">Webhook URL <span class="optional">(tuỳ chọn)</span></label>
<input class="form-input" id="f-webhook" type="url" placeholder="https://n8n.example.com/webhook/abc123" oninput="onWebhookUrlChange()">
<div class="form-hint">Sau mỗi lần chạy, kết quả sẽ được POST đến URL này dưới dạng JSON</div>
</div>
<div class="form-group" id="f-headers-group" style="display:none">
<label class="form-label">
Authorization Headers
<span class="optional">(tuỳ chọn)</span>
</label>
<textarea class="form-textarea" id="f-headers" rows="3"
placeholder='{"Authorization": "Bearer YOUR_TOKEN"}'
style="font-family:monospace;font-size:12px;min-height:70px"
oninput="validateHeadersJson()"></textarea>
<div class="form-hint" id="f-headers-hint">
Định dạng JSON. Ví dụ: <code style="background:var(--surface2);padding:1px 4px;border-radius:3px">{"Authorization":"Bearer xxx","X-Api-Key":"yyy"}</code>
</div>
</div>
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">
<label class="toggle" style="margin:0">
<input type="checkbox" id="f-enabled" checked>
<span class="toggle-track"></span>
</label>
Kích hoạt ngay sau khi tạo
</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeJobModal()">Huỷ</button>
<button class="btn btn-primary" id="modal-submit" onclick="submitJob()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
Lưu lịch chạy
</button>
</div>
</div>
</div>
<!-- Toast container -->
<div class="toast" id="toast"></div>
<script>
// ── State ────────────────────────────────────────────────────────────────────
const S = {
notebooks: [],
selectedNb: null,
sources: [],
jobs: [],
history: [],
historyAll: [],
editingJobId: null,
};
// ── Helpers ──────────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
async function api(path, opts = {}) {
const r = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
return r.json();
}
function toast(msg, type = '') {
const el = document.createElement('div');
el.className = 'toast-item' + (type ? ' ' + type : '');
el.textContent = msg;
$('toast').appendChild(el);
setTimeout(() => el.remove(), 3500);
}
function fmtDate(str) {
if (!str) return '—';
const d = new Date(str);
return d.toLocaleDateString('vi-VN', { day:'2-digit', month:'2-digit' }) + ' ' +
d.toLocaleTimeString('vi-VN', { hour:'2-digit', minute:'2-digit' });
}
function srcTypeBadge(type) {
const t = (type || 'other').toLowerCase();
const cls = ['pdf','url','text','audio','mp3','video','mp4'].includes(t) ? t : 'other';
const labels = { pdf:'PDF', url:'URL', text:'TEXT', audio:'Audio', mp3:'MP3', video:'Video', mp4:'MP4', other:'FILE' };
return `<span class="src-type-badge src-type-${cls}">${labels[cls]||cls.toUpperCase()}</span>`;
}
// ── Auth status ──────────────────────────────────────────────────────────────
async function checkAuth() {
try {
const r = await api('/api/auth/status');
const dot = $('auth-dot'), lbl = $('auth-label');
if (r.authenticated) {
dot.className = 'auth-dot ok';
lbl.textContent = 'Đã đăng nhập';
} else {
dot.className = 'auth-dot';
lbl.textContent = 'Chưa đăng nhập';
}
} catch { $('auth-label').textContent = 'Mất kết nối'; }
}
// ── Tab switching ────────────────────────────────────────────────────────────
function switchTab(tab) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(el => el.classList.remove('active'));
$('tab-' + tab).classList.add('active');
document.querySelector(`.nav-btn[data-tab="${tab}"]`).classList.add('active');
if (tab === 'history') loadHistory();
if (tab === 'cron') loadJobs();
}
// ── Notebooks ────────────────────────────────────────────────────────────────
async function loadNotebooks() {
$('nb-list').innerHTML = '<div class="loading-rows"><div class="skeleton" style="width:80%"></div><div class="skeleton" style="width:65%"></div><div class="skeleton" style="width:75%"></div></div>';
try {
const r = await api('/api/notebooks');
if (!r.ok) throw new Error(r.error);
S.notebooks = r.notebooks || [];
$('badge-notebooks').textContent = S.notebooks.length;
renderNotebookList();
populateNbSelects();
} catch (e) {
$('nb-list').innerHTML = `<div class="panel-empty"><p>Lỗi: ${e.message}</p></div>`;
}
}
function renderNotebookList() {
if (!S.notebooks.length) {
$('nb-list').innerHTML = '<div class="panel-empty"><p>Không có notebook nào</p></div>';
return;
}
$('nb-list').innerHTML = S.notebooks.map(nb => `
<li class="nb-item${S.selectedNb?.id === nb.id ? ' selected' : ''}"
onclick="selectNotebook('${nb.id}')">
<div class="nb-icon">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/>
</svg>
</div>
<div class="nb-info">
<div class="nb-name" title="${nb.title || 'Không có tiêu đề'}">${nb.title || '(Không có tiêu đề)'}</div>
</div>
<span class="nb-src-badge">—</span>
</li>
`).join('');
}
async function selectNotebook(id) {
S.selectedNb = S.notebooks.find(n => n.id === id);
renderNotebookList();
renderSourcePanel('loading');
try {
const r = await api(`/api/notebooks/${id}/sources?timeout=30`);
if (!r.ok) throw new Error(r.error);
S.sources = r.sources || [];
// Update source badge
const item = document.querySelector(`.nb-item.selected .nb-src-badge`);
if (item) item.textContent = r.total || '0';
renderSourcePanel();
} catch (e) {
renderSourcePanel('error', e.message);
}
}
function renderSourcePanel(state, errMsg) {
const nb = S.selectedNb;
if (!nb) return;
if (state === 'loading') {
$('nb-panel').innerHTML = `
<div class="card">
<div class="card-header">
<span class="card-title">${nb.title || 'Notebook'}</span>
<span class="card-sub">Đang tải sources...</span>
</div>
<div class="loading-rows">
<div class="skeleton"></div><div class="skeleton" style="width:75%"></div><div class="skeleton" style="width:85%"></div>
</div>
</div>`;
return;
}
if (state === 'error') {
$('nb-panel').innerHTML = `
<div class="card">
<div class="card-header"><span class="card-title">${nb.title}</span></div>
<div class="panel-empty"><p>Lỗi tải sources: ${errMsg}</p></div>
</div>`;
return;
}
const srcHtml = S.sources.length
? `<ul class="src-list">${S.sources.map(s => `
<li class="src-item">
${srcTypeBadge(s.type)}
<span class="src-name" title="${s.title || ''}">${s.title || '(Không có tiêu đề)'}</span>
${s.loading ? '<span class="src-loading"><span class="spinner" style="width:12px;height:12px"></span> Đang xử lý...</span>' : ''}
</li>`).join('')}
</ul>`
: '<div class="panel-empty"><p>Notebook này chưa có source nào</p></div>';
$('nb-panel').innerHTML = `
<div class="card">
<div class="card-header">
<div>
<div class="card-title">${nb.title || 'Notebook'}</div>
<div class="card-sub">${S.sources.length} nguồn tài liệu${S.sources.some(s=>s.loading) ? ' · Có source đang xử lý...' : ''}</div>
</div>
<button class="btn btn-primary btn-sm" onclick="prefillJobForNb('${nb.id}','${(nb.title||'').replace(/'/g,"\\'")}');switchTab('cron')">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Tạo lịch chạy
</button>
</div>
${srcHtml}
</div>`;
}
// ── Cron Jobs ────────────────────────────────────────────────────────────────
async function loadJobs() {
$('job-list').innerHTML = '<div class="loading-rows"><div class="skeleton"></div><div class="skeleton" style="width:80%"></div></div>';
try {
const r = await api('/api/cron');
if (!r.ok) throw new Error(r.error);
S.jobs = r.jobs || [];
$('badge-cron').textContent = S.jobs.length;
$('cron-count').textContent = `${S.jobs.length} lịch · ${S.jobs.filter(j=>j.enabled).length} đang bật`;
renderJobList();
} catch (e) {
$('job-list').innerHTML = `<div class="panel-empty"><p>Lỗi: ${e.message}</p></div>`;
}
}
function renderJobList() {
if (!S.jobs.length) {
$('job-list').innerHTML = `
<div class="card">
<div class="panel-empty">
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<p>Chưa có lịch chạy nào. Tạo mới để bắt đầu.</p>
</div>
</div>`;
return;
}
$('job-list').innerHTML = S.jobs.map(job => `
<div class="job-card" id="job-${job.id}">
<div>
<div class="job-notebook">${job.notebook_title || job.notebook_id}</div>
${job.label ? `<div class="job-label">${job.label}</div>` : ''}
<div class="job-message">${job.message}</div>
<div class="job-meta">
<span class="job-meta-item">
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
${job.schedule_display || job.cron_expr}
</span>
${job.webhook_url ? `
<span class="job-meta-item" title="${job.webhook_url}">
<svg viewBox="0 0 24 24" fill="none"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Webhook
</span>` : ''}
</div>
</div>
<div class="job-actions">
<label class="toggle" title="${job.enabled ? 'Tắt' : 'Bật'}" onclick="toggleJob(${job.id})">
<input type="checkbox" ${job.enabled ? 'checked' : ''} onclick="event.preventDefault()">
<span class="toggle-track"></span>
</label>
<div class="job-btns">
<button class="btn-icon" onclick="runJobNow(${job.id})" title="Chạy ngay">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
<button class="btn-icon" onclick="openJobModal(${job.id})" title="Sửa">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="btn-icon danger" onclick="deleteJob(${job.id})" title="Xoá">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
</button>
</div>
</div>
</div>
`).join('');
}
async function toggleJob(id) {
try {
const r = await api(`/api/cron/${id}/toggle`, { method: 'PATCH' });
if (!r.ok) throw new Error(r.error);
const idx = S.jobs.findIndex(j => j.id === id);
if (idx >= 0) S.jobs[idx] = r.job;
renderJobList();
$('cron-count').textContent = `${S.jobs.length} lịch · ${S.jobs.filter(j=>j.enabled).length} đang bật`;
toast(r.job.enabled ? 'Đã bật lịch chạy' : 'Đã tắt lịch chạy', r.job.enabled ? 'success' : '');
} catch (e) { toast('Lỗi: ' + e.message, 'error'); }
}
async function runJobNow(id) {
try {
const r = await api(`/api/cron/${id}/run`, { method: 'POST' });
if (!r.ok) throw new Error(r.error);
toast('Đang chạy... Kết quả sẽ lưu vào Lịch sử', 'success');
} catch (e) { toast('Lỗi: ' + e.message, 'error'); }
}
async function deleteJob(id) {
if (!confirm('Xoá lịch chạy này?')) return;
try {
const r = await api(`/api/cron/${id}`, { method: 'DELETE' });
if (!r.ok) throw new Error(r.error);
S.jobs = S.jobs.filter(j => j.id !== id);
renderJobList();
$('badge-cron').textContent = S.jobs.length;
$('cron-count').textContent = `${S.jobs.length} lịch · ${S.jobs.filter(j=>j.enabled).length} đang bật`;
toast('Đã xoá lịch chạy', 'success');
} catch (e) { toast('Lỗi: ' + e.message, 'error'); }
}
// ── History ──────────────────────────────────────────────────────────────────
async function loadHistory() {
$('history-body').innerHTML = '<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--text3)">Đang tải...</td></tr>';
try {
const nbId = $('history-nb-filter').value;
const r = await api(`/api/history?limit=200${nbId ? '&notebook_id=' + nbId : ''}`);
if (!r.ok) throw new Error(r.error);
S.historyAll = r.history || [];
$('badge-history').textContent = S.historyAll.length;
$('history-count').textContent = `${S.historyAll.length} lần chạy gần nhất`;
renderHistory();
} catch (e) {
$('history-body').innerHTML = `<tr><td colspan="5" style="text-align:center;padding:24px;color:var(--red)">Lỗi: ${e.message}</td></tr>`;
}
}
function renderHistory() {
const statusFilter = $('history-status-filter').value;
const items = statusFilter
? S.historyAll.filter(h => h.status === statusFilter)
: S.historyAll;
if (!items.length) {
$('history-body').innerHTML = '<tr><td colspan="5" style="text-align:center;padding:32px;color:var(--text3)">Chưa có lịch sử nào</td></tr>';
return;
}
$('history-body').innerHTML = items.map(h => `
<tr class="expandable" onclick="toggleHistoryRow(${h.id}, this)">
<td style="color:var(--text2);white-space:nowrap;font-size:12px">${fmtDate(h.ran_at)}</td>
<td><span class="truncate" style="max-width:160px;display:block" title="${h.notebook_title||h.notebook_id}">${h.notebook_title||h.notebook_id}</span></td>
<td><span class="truncate" style="max-width:240px;display:block;color:var(--text2)" title="${h.message}">${h.message}</span></td>
<td>
<span class="status-badge ${h.status==='success'?'status-ok':'status-err'}">
${h.status==='success'?'✓ OK':'✗ Lỗi'}
</span>
</td>
<td style="text-align:center">
${h.webhook_url
? (h.webhook_ok ? '<span class="webhook-ok" title="Webhook thành công">✓</span>' : '<span class="webhook-err" title="Webhook thất bại">✗</span>')
: '<span class="webhook-none">—</span>'}
</td>
</tr>
<tr id="hist-detail-${h.id}" style="display:none">
<td colspan="5" style="padding:0;background:var(--surface2)">
<div style="padding:12px 14px">
${h.error ? `<div style="color:var(--red);font-size:12px;margin-bottom:8px">Lỗi: ${h.error}</div>` : ''}
${h.answer ? `
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:6px">Câu trả lời</div>
<div class="history-answer-text">${h.answer}</div>` : '<div style="color:var(--text3);font-size:12px">Không có câu trả lời</div>'}
${h.webhook_url ? `<div style="margin-top:8px;font-size:11px;color:var(--text3)">Webhook: ${h.webhook_url} · HTTP ${h.webhook_status||'—'}</div>` : ''}
</div>
</td>
</tr>
`).join('');
}
function toggleHistoryRow(id, tr) {
const detail = $('hist-detail-' + id);
if (!detail) return;
const isOpen = detail.style.display !== 'none';
detail.style.display = isOpen ? 'none' : 'table-row';
tr.classList.toggle('expanded', !isOpen);
}
// ── Modal: Create/Edit Job ────────────────────────────────────────────────────
function buildHourOptions() {
const sel = $('f-hour');
sel.innerHTML = Array.from({length:24}, (_,i) =>
`<option value="${i}">${String(i).padStart(2,'0')}</option>`
).join('');
sel.value = 8;
sel.addEventListener('change', updateCronPreview);
}
function buildMinuteOptions() {
const sel = $('f-minute');
sel.innerHTML = [0,5,10,15,20,25,30,35,40,45,50,55].map(m =>
`<option value="${m}">${String(m).padStart(2,'0')}</option>`
).join('');
sel.value = 0;
sel.addEventListener('change', updateCronPreview);
}
function populateNbSelects() {
const opts = S.notebooks.map(n => `<option value="${n.id}" data-title="${n.title||''}">${n.title||n.id}</option>`).join('');
$('f-notebook').innerHTML = '<option value="">— Chọn notebook —</option>' + opts;
// History filter
const histSel = $('history-nb-filter');
const current = histSel.value;
histSel.innerHTML = '<option value="">Tất cả notebook</option>' + opts;
histSel.value = current;
}
function onNotebookSelectChange() { updateCronPreview(); }
function onWebhookUrlChange() {
const hasUrl = $('f-webhook').value.trim().length > 0;
$('f-headers-group').style.display = hasUrl ? 'block' : 'none';
if (!hasUrl) $('f-headers').value = '';
}
function validateHeadersJson() {
const val = $('f-headers').value.trim();
const hint = $('f-headers-hint');
if (!val) { hint.style.color = ''; return true; }
try {
const parsed = JSON.parse(val);
if (typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Phải là object');
hint.style.color = 'var(--green)';
hint.textContent = `✓ Hợp lệ — ${Object.keys(parsed).length} header(s): ${Object.keys(parsed).join(', ')}`;
return true;
} catch (e) {
hint.style.color = 'var(--red)';
hint.textContent = `✗ JSON không hợp lệ: ${e.message}`;
return false;
}
}
function onRepeatChange() {
const val = $('f-repeat').value;
$('f-custom-days').style.display = val === 'custom' ? 'block' : 'none';
// Uncheck all when switching away from custom
if (val !== 'custom') {
document.querySelectorAll('.day-check').forEach(c => c.checked = false);
}
updateCronPreview();
}
function buildCronExpr() {
const h = $('f-hour').value;
const m = $('f-minute').value;
const rep = $('f-repeat').value;
if (rep === 'daily') return { expr: `${m} ${h} * * *`, display: `${h.padStart?h:String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} hàng ngày` };
if (rep === 'weekdays') return { expr: `${m} ${h} * * 1-5`, display: `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} T2T6` };
if (rep === 'weekends') return { expr: `${m} ${h} * * 0,6`, display: `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} T7CN` };
// custom
const checked = [...document.querySelectorAll('.day-check:checked')].map(c => c.value);
if (!checked.length) return { expr: '', display: '' };
const dayNames = {0:'CN',1:'T2',2:'T3',3:'T4',4:'T5',5:'T6',6:'T7'};
const daysLabel = checked.sort().map(d => dayNames[d]).join(', ');
return { expr: `${m} ${h} * * ${checked.sort().join(',')}`, display: `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')} ${daysLabel}` };
}
function updateCronPreview() {
const { expr, display } = buildCronExpr();
$('f-cron-preview').textContent = expr
? `Cron: ${expr} → Chạy lúc ${display}`
: 'Vui lòng chọn ít nhất 1 ngày';
}
function parseCronToForm(expr) {
// Parse simple patterns: "M H * * DAYS"
const parts = (expr || '').trim().split(/\s+/);
if (parts.length !== 5) return;
const [m, h, , , days] = parts;
$('f-minute').value = m;
$('f-hour').value = h;
if (days === '*') { $('f-repeat').value = 'daily'; }
else if (days === '1-5') { $('f-repeat').value = 'weekdays'; }
else if (days === '0,6') { $('f-repeat').value = 'weekends'; }
else {
$('f-repeat').value = 'custom';
$('f-custom-days').style.display = 'block';
days.split(',').forEach(d => {
const cb = document.getElementById('d' + d);
if (cb) cb.checked = true;
});
}
onRepeatChange();
}
function openJobModal(jobId) {
S.editingJobId = jobId || null;
$('modal-title').textContent = jobId ? 'Chỉnh sửa lịch chạy' : 'Tạo lịch chạy mới';
// Reset form
$('f-notebook').value = '';
$('f-label').value = '';
$('f-message').value = '';
$('f-webhook').value = '';
$('f-headers').value = '';
$('f-headers-group').style.display = 'none';
$('f-enabled').checked = true;
$('f-repeat').value = 'daily';
$('f-hour').value = 8;
$('f-minute').value = 0;
$('f-custom-days').style.display = 'none';
document.querySelectorAll('.day-check').forEach(c => c.checked = false);
// Reset headers hint
const hint = $('f-headers-hint');
hint.style.color = '';
hint.innerHTML = 'Định dạng JSON. Ví dụ: <code style="background:var(--surface2);padding:1px 4px;border-radius:3px">{"Authorization":"Bearer xxx","X-Api-Key":"yyy"}</code>';
if (jobId) {
const job = S.jobs.find(j => j.id === jobId);
if (job) {
$('f-notebook').value = job.notebook_id;
$('f-label').value = job.label || '';
$('f-message').value = job.message;
$('f-webhook').value = job.webhook_url || '';
$('f-enabled').checked = !!job.enabled;
// Load headers
try {
const h = JSON.parse(job.webhook_headers || '{}');
if (Object.keys(h).length > 0) {
$('f-headers').value = JSON.stringify(h, null, 2);
$('f-headers-group').style.display = 'block';
validateHeadersJson();
}
} catch {}
parseCronToForm(job.cron_expr);
}
}
updateCronPreview();
$('modal').classList.add('open');
setTimeout(() => $('f-message').focus(), 100);
}
function prefillJobForNb(nbId, nbTitle) {
openJobModal();
$('f-notebook').value = nbId;
}
function closeJobModal(e) {
if (e && e.target !== $('modal')) return;
$('modal').classList.remove('open');
S.editingJobId = null;
}
async function submitJob() {
const nbSel = $('f-notebook');
const notebook_id = nbSel.value;
const notebook_title = nbSel.selectedOptions[0]?.dataset.title || '';
const label = $('f-label').value.trim();
const message = $('f-message').value.trim();
const webhook_url = $('f-webhook').value.trim() || null;
const enabled = $('f-enabled').checked;
const { expr: cron_expr, display: schedule_display } = buildCronExpr();
// Parse headers
let webhook_headers = '{}';
const rawHeaders = $('f-headers').value.trim();
if (rawHeaders) {
if (!validateHeadersJson()) return toast('Headers JSON không hợp lệ', 'error');
webhook_headers = rawHeaders;
}
if (!notebook_id) return toast('Vui lòng chọn notebook', 'error');
if (!message) return toast('Vui lòng nhập câu hỏi', 'error');
if (!cron_expr) return toast('Vui lòng chọn ít nhất 1 ngày chạy', 'error');
const btn = $('modal-submit');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Đang lưu...';
try {
const body = { notebook_id, notebook_title, label, message, cron_expr, schedule_display, webhook_url, webhook_headers, enabled };
const isEdit = !!S.editingJobId;
const r = await api(isEdit ? `/api/cron/${S.editingJobId}` : '/api/cron', {
method: isEdit ? 'PUT' : 'POST',
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(r.error);
if (isEdit) {
const idx = S.jobs.findIndex(j => j.id === S.editingJobId);
if (idx >= 0) S.jobs[idx] = r.job;
} else {
S.jobs.unshift(r.job);
}
renderJobList();
$('badge-cron').textContent = S.jobs.length;
$('cron-count').textContent = `${S.jobs.length} lịch · ${S.jobs.filter(j=>j.enabled).length} đang bật`;
$('modal').classList.remove('open');
toast(isEdit ? 'Đã cập nhật lịch chạy' : 'Đã tạo lịch chạy mới', 'success');
} catch (e) {
toast('Lỗi: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Lưu lịch chạy';
}
}
// Keyboard shortcut: Escape to close modal
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeJobModal();
});
// ── Init ─────────────────────────────────────────────────────────────────────
buildHourOptions();
buildMinuteOptions();
checkAuth();
loadNotebooks();
loadJobs();
// Auto-refresh auth every 60s
setInterval(checkAuth, 60_000);
</script>
</body>
</html>