tt (#1)
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m40s
All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m40s
Reviewed-on: #1 Co-authored-by: Pham Hung <22.phamviethung.12a12@gmail.com> Co-committed-by: Pham Hung <22.phamviethung.12a12@gmail.com>
This commit is contained in:
@@ -9,6 +9,47 @@ interface AppTemplatePickerModalProps {
|
||||
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,
|
||||
@@ -60,70 +101,110 @@ export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||
<div className="modal-container app-pool-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Chọn khung ứng dụng</h2>
|
||||
<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 danh sách ứng dụng được phép. Quản lý khung tại Hệ thống → Khung ứng dụng.
|
||||
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" onClick={onClose}>
|
||||
<button type="button" className="btn btn-secondary app-picker-close" onClick={onClose}>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-toolbar">
|
||||
<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
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
|
||||
<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="discovered-apps-empty discovered-apps-error">{error}</div>
|
||||
<div className="app-picker-state app-picker-state--error">{error}</div>
|
||||
) : loading && items.length === 0 ? (
|
||||
<div className="app-pool-status">Đang tải...</div>
|
||||
<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-pool-status">
|
||||
{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.'}
|
||||
<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) => (
|
||||
<div key={tpl.id} className="template-picker-card">
|
||||
<div className="template-picker-card-head">
|
||||
<strong>{tpl.name}</strong>
|
||||
<span className="template-picker-count">{countKeywords(tpl.keywords)} keyword</span>
|
||||
</div>
|
||||
{tpl.description && <p className="template-picker-desc">{tpl.description}</p>}
|
||||
<code className="template-picker-kw">{tpl.keywords}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm template-picker-apply"
|
||||
onClick={() => {
|
||||
onSelect(tpl.keywords);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Áp dụng khung
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{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">
|
||||
Đang có {countKeywords(allowedApps)} keyword trong whitelist hiện tại
|
||||
Whitelist hiện tại: <strong>{countKeywords(allowedApps)}</strong> keyword
|
||||
{filtered.length > 0 ? ` · ${filtered.length} khung` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user