211 lines
6.8 KiB
TypeScript
211 lines
6.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { apiAppTemplates, type AppTemplateItem } from '../api';
|
|
import { AppPoolModal } from './AppPoolModal';
|
|
import { countKeywords, mergeKeywordCSV } from '../utils/appKeywords';
|
|
|
|
export function AppTemplatesSection() {
|
|
const [items, setItems] = useState<AppTemplateItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [editing, setEditing] = useState<AppTemplateItem | null>(null);
|
|
const [creating, setCreating] = useState(false);
|
|
const [name, setName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [keywords, setKeywords] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [poolOpen, setPoolOpen] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiAppTemplates.list();
|
|
setItems(res.data || []);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
const openCreate = () => {
|
|
setCreating(true);
|
|
setEditing(null);
|
|
setName('');
|
|
setDescription('');
|
|
setKeywords('');
|
|
setError('');
|
|
};
|
|
|
|
const openEdit = (tpl: AppTemplateItem) => {
|
|
setEditing(tpl);
|
|
setCreating(false);
|
|
setName(tpl.name);
|
|
setDescription(tpl.description || '');
|
|
setKeywords(tpl.keywords);
|
|
setError('');
|
|
};
|
|
|
|
const closeForm = () => {
|
|
setCreating(false);
|
|
setEditing(null);
|
|
setError('');
|
|
};
|
|
|
|
const save = async () => {
|
|
if (!name.trim()) {
|
|
setError('Nhập tên khung.');
|
|
return;
|
|
}
|
|
if (!keywords.trim()) {
|
|
setError('Thêm ít nhất một keyword.');
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError('');
|
|
try {
|
|
if (editing) {
|
|
await apiAppTemplates.update(editing.id, {
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
keywords,
|
|
});
|
|
} else {
|
|
await apiAppTemplates.create({
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
keywords,
|
|
});
|
|
}
|
|
closeForm();
|
|
await load();
|
|
} catch (e: any) {
|
|
setError(e?.message || 'Lỗi lưu khung');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const remove = async (tpl: AppTemplateItem) => {
|
|
if (!confirm(`Xóa khung "${tpl.name}"?`)) return;
|
|
try {
|
|
await apiAppTemplates.delete(tpl.id);
|
|
if (editing?.id === tpl.id) closeForm();
|
|
await load();
|
|
} catch (e: any) {
|
|
alert(e?.message || 'Không xóa được');
|
|
}
|
|
};
|
|
|
|
const addPoolKeyword = (kw: string) => {
|
|
setKeywords((prev) => mergeKeywordCSV(prev, kw));
|
|
};
|
|
|
|
const showForm = creating || editing;
|
|
|
|
return (
|
|
<div className="system-section">
|
|
<div className="system-block">
|
|
<div className="system-block-head">
|
|
<div>
|
|
<h2 className="system-block-title">Khung ứng dụng</h2>
|
|
<p className="system-block-desc">
|
|
Tạo bộ keyword whitelist dùng chung — ghép từ kho app hoặc tự nhập. Lớp học và phòng thi có thể áp dụng nhanh thay vì chỉ chọn từng app trong kho.
|
|
</p>
|
|
</div>
|
|
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
|
+ Tạo khung
|
|
</button>
|
|
</div>
|
|
|
|
{showForm && (
|
|
<div className="system-form-card">
|
|
<h3 className="system-form-title">{editing ? 'Sửa khung' : 'Khung mới'}</h3>
|
|
<div className="system-form-grid">
|
|
<label className="login-field">
|
|
<span>Tên khung</span>
|
|
<input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="VD: Java IDE, Thi cuối kỳ..."
|
|
/>
|
|
</label>
|
|
<label className="login-field system-form-full">
|
|
<span>Mô tả (tuỳ chọn)</span>
|
|
<input
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Ghi chú ngắn cho giáo viên"
|
|
/>
|
|
</label>
|
|
<label className="login-field system-form-full">
|
|
<span>Keywords (phân tách bằng dấu phẩy)</span>
|
|
<textarea
|
|
className="app-textarea"
|
|
rows={4}
|
|
value={keywords}
|
|
onChange={(e) => setKeywords(e.target.value)}
|
|
placeholder="chrome, vscode, cursor, goland, client"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="system-form-actions">
|
|
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
|
|
Thêm từ kho app
|
|
</button>
|
|
<div className="system-form-actions-right">
|
|
<button type="button" className="btn btn-ghost" onClick={closeForm}>
|
|
Hủy
|
|
</button>
|
|
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
|
|
{busy ? 'Đang lưu...' : 'Lưu khung'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{error && <div className="login-error" style={{ marginTop: '0.75rem' }}>{error}</div>}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="system-empty">Đang tải...</div>
|
|
) : items.length === 0 ? (
|
|
<div className="system-empty">
|
|
Chưa có khung nào. Bấm <strong>Tạo khung</strong> để ghép keyword từ kho hoặc tự ghi.
|
|
</div>
|
|
) : (
|
|
<div className="template-grid">
|
|
{items.map((tpl) => (
|
|
<article key={tpl.id} className="template-card">
|
|
<div className="template-card-head">
|
|
<h3>{tpl.name}</h3>
|
|
<span className="template-card-badge">{countKeywords(tpl.keywords)} kw</span>
|
|
</div>
|
|
{tpl.description && <p className="template-card-desc">{tpl.description}</p>}
|
|
<code className="template-card-kw">{tpl.keywords}</code>
|
|
<div className="template-card-actions">
|
|
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openEdit(tpl)}>
|
|
Sửa
|
|
</button>
|
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(tpl)}>
|
|
Xóa
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<AppPoolModal
|
|
open={poolOpen}
|
|
onClose={() => setPoolOpen(false)}
|
|
onSelect={addPoolKeyword}
|
|
allowedApps={keywords}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|