187 lines
7.0 KiB
TypeScript
187 lines
7.0 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { apiGitHubRepos, type GitHubRepoItem } from '../api';
|
|
|
|
export function GitHubReposPanel({ connected, canDeleteRepos = true }: { connected: boolean; canDeleteRepos?: boolean }) {
|
|
const [repos, setRepos] = useState<GitHubRepoItem[]>([]);
|
|
const [githubLogin, setGithubLogin] = useState('');
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [loading, setLoading] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [msg, setMsg] = useState('');
|
|
const [err, setErr] = useState('');
|
|
|
|
const load = useCallback(async () => {
|
|
if (!connected) {
|
|
setRepos([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setErr('');
|
|
try {
|
|
const res = await apiGitHubRepos.list();
|
|
setRepos(res.data || []);
|
|
setGithubLogin(res.githubLogin || '');
|
|
setSelected(new Set());
|
|
} catch (e: any) {
|
|
setErr(e?.message || 'Không tải repo');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [connected]);
|
|
|
|
useEffect(() => { load().catch(console.error); }, [load]);
|
|
|
|
const toggle = (key: string) => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const toggleAll = () => {
|
|
if (selected.size === repos.length) setSelected(new Set());
|
|
else setSelected(new Set(repos.map((r) => r.key)));
|
|
};
|
|
|
|
const deleteSelected = async () => {
|
|
if (selected.size === 0) return;
|
|
if (!canDeleteRepos) {
|
|
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
|
|
return;
|
|
}
|
|
if (!confirm(`Xóa ${selected.size} repo trên GitHub? Hành động không hoàn tác.`)) return;
|
|
setErr(''); setMsg('');
|
|
setDeleting(true);
|
|
try {
|
|
const res = await apiGitHubRepos.bulkDelete([...selected]);
|
|
if (res.deleted > 0) {
|
|
setMsg(res.message || `Đã xóa ${res.deleted} repo`);
|
|
if (res.failures?.length) setErr(res.failures.join('; '));
|
|
} else {
|
|
setMsg('');
|
|
setErr(res.failures?.join('; ') || 'Không xóa được repo nào — thử ngắt kết nối rồi kết nối lại GitHub.');
|
|
}
|
|
await load();
|
|
} catch (e: any) {
|
|
setErr(e?.message || 'Xóa thất bại');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const deleteOne = async (repo: GitHubRepoItem) => {
|
|
if (!canDeleteRepos) {
|
|
setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.');
|
|
return;
|
|
}
|
|
if (!confirm(`Xóa repo "${repo.repoName}" trên GitHub?`)) return;
|
|
setErr(''); setMsg('');
|
|
setDeleting(true);
|
|
try {
|
|
const res = await apiGitHubRepos.bulkDelete([repo.key]);
|
|
if (res.deleted > 0) {
|
|
setMsg(res.message || 'Đã xóa repo');
|
|
} else {
|
|
setMsg('');
|
|
setErr(res.failures?.join('; ') || 'Không xóa được repo — thử ngắt kết nối rồi kết nối lại GitHub.');
|
|
}
|
|
await load();
|
|
} catch (e: any) {
|
|
setErr(e?.message || 'Xóa thất bại');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
if (!connected) return null;
|
|
|
|
return (
|
|
<div className="github-repos-panel">
|
|
<p className="exam-git-hint" style={{ marginTop: 0 }}>
|
|
Toàn bộ repo trên tài khoản GitHub{githubLogin ? ` @${githubLogin}` : ''} — có thể chọn và xóa nhiều repo cùng lúc.
|
|
</p>
|
|
<div className="github-repos-toolbar">
|
|
<button type="button" className="btn btn-secondary btn-sm" disabled={loading} onClick={() => load()}>
|
|
{loading ? 'Đang tải từ GitHub...' : 'Làm mới'}
|
|
</button>
|
|
{repos.length > 0 && (
|
|
<>
|
|
<button type="button" className="btn btn-secondary btn-sm" onClick={toggleAll}>
|
|
{selected.size === repos.length ? 'Bỏ chọn' : 'Chọn tất cả'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm learning-btn-danger"
|
|
disabled={deleting || selected.size === 0 || !canDeleteRepos}
|
|
onClick={deleteSelected}
|
|
>
|
|
{deleting ? 'Đang xóa...' : `Xóa đã chọn (${selected.size})`}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{err && <div className="login-error">{err}</div>}
|
|
{msg && <div className="login-success">{msg}</div>}
|
|
{repos.length === 0 && !loading ? (
|
|
<p className="exam-git-hint">Không có repo nào trên tài khoản GitHub (hoặc token thiếu quyền đọc repo).</p>
|
|
) : (
|
|
<div className="github-repos-table-wrap table-wrapper">
|
|
<table className="data-table github-repos-table">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 36 }} />
|
|
<th>Repo</th>
|
|
<th>Loại</th>
|
|
<th>Phòng / SV</th>
|
|
<th>Cập nhật</th>
|
|
<th />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{repos.map((r) => (
|
|
<tr key={r.key}>
|
|
<td>
|
|
<input type="checkbox" checked={selected.has(r.key)} onChange={() => toggle(r.key)} />
|
|
</td>
|
|
<td>
|
|
<a href={r.repoHtmlUrl} target="_blank" rel="noreferrer" className="github-repo-link">
|
|
{r.repoName}
|
|
</a>
|
|
{r.private && <span className="badge badge-muted" style={{ marginLeft: 6 }}>private</span>}
|
|
</td>
|
|
<td>
|
|
{r.publishMode === 'student' ? (
|
|
<span className="badge badge-info">Từng SV</span>
|
|
) : r.publishMode === 'room' ? (
|
|
<span className="badge badge-muted">Gộp phòng</span>
|
|
) : r.fromSimpleCare ? (
|
|
<span className="badge badge-muted">Simple Care</span>
|
|
) : (
|
|
<span className="badge badge-muted">Khác</span>
|
|
)}
|
|
</td>
|
|
<td style={{ fontSize: '0.85rem' }}>
|
|
{r.examRoomName && <div>{r.examRoomName}</div>}
|
|
{r.studentLabel && <div className="text-muted">{r.studentLabel}</div>}
|
|
{!r.examRoomName && !r.studentLabel && '—'}
|
|
</td>
|
|
<td style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
{r.updatedAt ? new Date(r.updatedAt).toLocaleString('vi-VN') : new Date(r.createdAt).toLocaleString('vi-VN')}
|
|
</td>
|
|
<td style={{ textAlign: 'right' }}>
|
|
<button type="button" className="btn btn-ghost btn-sm learning-btn-danger" disabled={deleting || !canDeleteRepos} onClick={() => deleteOne(r)}>
|
|
Xóa
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|