Some checks failed
Deploy on Master Change / deploy (pull_request) Has been cancelled
213 lines
7.5 KiB
TypeScript
213 lines
7.5 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import { apiAppTemplates, type AppTemplateItem } from '../api';
|
|
import { countKeywords } from '../utils/appKeywords';
|
|
|
|
interface AppTemplatePickerModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSelect: (keywords: string) => void;
|
|
allowedApps: string;
|
|
}
|
|
|
|
type IconProps = { size?: number };
|
|
|
|
const IconLayers = ({ size = 20 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="m12 2 9 4.5-9 4.5L3 6.5 12 2z" />
|
|
<path d="m3 12 9 4.5 9-4.5" />
|
|
<path d="m3 17.5 9 4.5 9-4.5" />
|
|
</svg>
|
|
);
|
|
|
|
const IconSearch = ({ size = 15 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
);
|
|
|
|
const IconRefresh = ({ size = 15 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M21 12a9 9 0 1 1-2.6-6.3" />
|
|
<polyline points="21 3 21 9 15 9" />
|
|
</svg>
|
|
);
|
|
|
|
const IconCheck = ({ size = 14 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<path d="M20 6 9 17l-5-5" />
|
|
</svg>
|
|
);
|
|
|
|
const IconInbox = ({ size = 32 }: IconProps) => (
|
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
|
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
|
</svg>
|
|
);
|
|
|
|
function keywordList(csv: string): string[] {
|
|
return csv.split(',').map((s) => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
|
open,
|
|
onClose,
|
|
onSelect,
|
|
allowedApps,
|
|
}) => {
|
|
const [items, setItems] = useState<AppTemplateItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const res = await apiAppTemplates.list();
|
|
setItems(res.data || []);
|
|
} catch (e: any) {
|
|
setItems([]);
|
|
setError(e?.message || 'Không tải được khung ứng dụng');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
load();
|
|
}, [open, load]);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setSearch('');
|
|
setError(null);
|
|
}
|
|
}, [open]);
|
|
|
|
if (!open) return null;
|
|
|
|
const q = search.trim().toLowerCase();
|
|
const filtered = q
|
|
? items.filter(
|
|
(t) =>
|
|
t.name.toLowerCase().includes(q) ||
|
|
t.description?.toLowerCase().includes(q) ||
|
|
t.keywords.toLowerCase().includes(q),
|
|
)
|
|
: items;
|
|
|
|
return (
|
|
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
|
<div className="modal-container app-pool-modal app-picker-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header app-picker-header">
|
|
<div className="app-picker-header-text">
|
|
<h2 className="modal-title app-picker-title">
|
|
<span className="app-picker-title-icon" aria-hidden>
|
|
<IconLayers />
|
|
</span>
|
|
Chọn khung ứng dụng
|
|
</h2>
|
|
<p className="app-pool-modal-sub">
|
|
Ghép bộ keyword đã lưu vào whitelist. Quản lý khung tại Hệ thống → Khung ứng dụng.
|
|
</p>
|
|
</div>
|
|
<button type="button" className="btn btn-secondary app-picker-close" onClick={onClose}>
|
|
Đóng
|
|
</button>
|
|
</div>
|
|
|
|
<div className="app-pool-toolbar">
|
|
<div className="app-picker-search-wrap">
|
|
<span className="app-picker-search-icon" aria-hidden>
|
|
<IconSearch />
|
|
</span>
|
|
<input
|
|
type="search"
|
|
className="app-pool-search"
|
|
placeholder="Tìm theo tên, mô tả, keyword..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary app-picker-refresh"
|
|
onClick={load}
|
|
disabled={loading}
|
|
>
|
|
<IconRefresh />
|
|
{loading ? 'Đang tải...' : 'Làm mới'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="app-pool-body">
|
|
{error ? (
|
|
<div className="app-picker-state app-picker-state--error">{error}</div>
|
|
) : loading && items.length === 0 ? (
|
|
<div className="app-picker-state">
|
|
<div className="sync-spinner" style={{ width: 28, height: 28 }} />
|
|
<p>Đang tải khung ứng dụng...</p>
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="app-picker-state">
|
|
<IconInbox />
|
|
<p>
|
|
{q
|
|
? `Không tìm thấy “${search}”`
|
|
: 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="template-picker-list">
|
|
{filtered.map((tpl) => {
|
|
const kws = keywordList(tpl.keywords);
|
|
const count = countKeywords(tpl.keywords);
|
|
return (
|
|
<article key={tpl.id} className="template-picker-card">
|
|
<div className="template-picker-card-main">
|
|
<div className="template-picker-card-head">
|
|
<strong className="template-picker-name">{tpl.name}</strong>
|
|
<span className="template-picker-count">{count} keyword</span>
|
|
</div>
|
|
{tpl.description ? (
|
|
<p className="template-picker-desc">{tpl.description}</p>
|
|
) : null}
|
|
<div className="template-picker-chips" title={tpl.keywords}>
|
|
{kws.map((kw) => (
|
|
<span key={kw} className="template-picker-chip">
|
|
{kw}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm template-picker-apply"
|
|
onClick={() => {
|
|
onSelect(tpl.keywords);
|
|
onClose();
|
|
}}
|
|
>
|
|
<IconCheck size={13} />
|
|
Áp dụng
|
|
</button>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="app-pool-footer">
|
|
Whitelist hiện tại: <strong>{countKeywords(allowedApps)}</strong> keyword
|
|
{filtered.length > 0 ? ` · ${filtered.length} khung` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|