Spaces:
Running
Running
Download frontend/src/components/ui.jsx from tntgroup/website_content: direct link, hf CLI and curl.
- Browser
- Download file 8 kB
-
https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/ui.jsx
- Command line
-
hf download hf://spaces/tntgroup/website_content/frontend/src/components/ui.jsx
-
curl -L -o ui.jsx https://huggingface.co/spaces/tntgroup/website_content/resolve/main/frontend/src/components/ui.jsx
8 kB
| import { useRef, useState } from "react"; | |
| import { IcUpload, IcInfo } from "./Icons"; | |
| export function Toggle({ on, onChange }) { | |
| return ( | |
| <div className={"toggle" + (on ? " on" : "")} onClick={() => onChange(!on)} role="switch" aria-checked={on}> | |
| <div className="knob" /> | |
| </div> | |
| ); | |
| } | |
| export function ProcessRail({ title = "Quy trình xử lý", steps, tip }) { | |
| return ( | |
| <div className="rail"> | |
| <div className="rail-title">{title}</div> | |
| {steps.map((s, i) => ( | |
| <div className="rail-step" key={i}> | |
| <div className="num">{i + 1}</div> | |
| <div className="body"> | |
| <div className="t">{s.t}</div> | |
| <div className="d">{s.d}</div> | |
| </div> | |
| </div> | |
| ))} | |
| {tip && <div className="tip"><b>Mẹo:</b> {tip}</div>} | |
| </div> | |
| ); | |
| } | |
| // Duyệt ĐỆ QUY một thư mục được kéo-thả (DataTransferItem -> FileSystemEntry). | |
| // Trình duyệt KHÔNG đưa file con của thư mục vào dataTransfer.files, nên phải tự | |
| // đọc qua webkitGetAsEntry để lấy hết file trong (các) thư mục. | |
| async function readDropEntries(dataTransfer) { | |
| const items = Array.from(dataTransfer.items || []); | |
| const entries = items | |
| .map((it) => (it.webkitGetAsEntry ? it.webkitGetAsEntry() : null)) | |
| .filter(Boolean); | |
| if (!entries.length) return Array.from(dataTransfer.files || []); | |
| const out = []; | |
| const walk = (entry) => | |
| new Promise((resolve) => { | |
| if (entry.isFile) { | |
| entry.file((f) => { out.push(f); resolve(); }, () => resolve()); | |
| } else if (entry.isDirectory) { | |
| const reader = entry.createReader(); | |
| const readBatch = () => | |
| reader.readEntries(async (batch) => { | |
| if (!batch.length) return resolve(); | |
| await Promise.all(batch.map(walk)); | |
| readBatch(); // readEntries trả theo lô -> đọc tới khi hết | |
| }, () => resolve()); | |
| readBatch(); | |
| } else resolve(); | |
| }); | |
| await Promise.all(entries.map(walk)); | |
| return out; | |
| } | |
| export function FileDrop({ accept = "video/*", multiple = true, onFiles, title, hint, compact, folder = false }) { | |
| const ref = useRef(); | |
| const folderRef = useRef(); | |
| const [drag, setDrag] = useState(false); | |
| const pick = (list) => list && list.length && onFiles(Array.from(list)); | |
| const onDrop = async (e) => { | |
| e.preventDefault(); | |
| setDrag(false); | |
| // Hỗ trợ kéo CẢ THƯ MỤC: nếu có entries thì duyệt đệ quy, lọc theo accept. | |
| let files = await readDropEntries(e.dataTransfer); | |
| if (accept && accept !== "*" && files.length) { | |
| const base = accept.replace("/*", ""); // "video" | "audio" | |
| const filtered = files.filter((f) => (f.type || "").startsWith(base)); | |
| if (filtered.length) files = filtered; // chỉ lọc khi còn file hợp lệ | |
| } | |
| pick(files); | |
| }; | |
| return ( | |
| <div | |
| className={"dropzone" + (drag ? " drag" : "")} | |
| style={compact ? { padding: "22px 16px" } : null} | |
| onDragOver={(e) => { e.preventDefault(); setDrag(true); }} | |
| onDragLeave={() => setDrag(false)} | |
| onDrop={onDrop} | |
| onClick={() => ref.current.click()} | |
| > | |
| <div className="chip"><IcUpload size={24} /></div> | |
| <div className="big">{title || "Kéo & thả file vào đây"}</div> | |
| <div className="hint">{hint || "Hỗ trợ MP4, MOV, AVI, MKV"}</div> | |
| {folder && ( | |
| <button className="btn sm mt12" type="button" | |
| onClick={(e) => { e.stopPropagation(); folderRef.current.click(); }}> | |
| Chọn cả thư mục | |
| </button> | |
| )} | |
| <input ref={ref} type="file" accept={accept} multiple={multiple} hidden | |
| onChange={(e) => pick(e.target.files)} /> | |
| {folder && ( | |
| <input ref={folderRef} type="file" hidden webkitdirectory="" directory="" | |
| multiple onChange={(e) => pick(e.target.files)} /> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Hiển thị tiến độ + log + danh sách item của một job đang chạy. | |
| export function JobView({ job }) { | |
| if (!job) return null; | |
| const pct = Math.round((job.progress || 0) * 100); | |
| return ( | |
| <div className="card mt16"> | |
| <div className="flex between center mt8" style={{ marginTop: 0 }}> | |
| <b className="small">Tiến độ xử lý</b> | |
| <span className="small muted">{pct}% · {job.status}</span> | |
| </div> | |
| <div className="progress mt8"><i style={{ width: pct + "%" }} /></div> | |
| {job.items?.length > 0 && ( | |
| <table className="list mt16"> | |
| <tbody> | |
| {job.items.map((it) => ( | |
| <tr key={it.id}> | |
| <td>{it.label}</td> | |
| <td style={{ width: 120 }}> | |
| <span className={"badge " + (it.status || "")}> | |
| <span className="dot" /> | |
| {{ queued: "chờ", running: "đang xử lý", done: "xong", error: "lỗi" }[it.status] || it.status} | |
| </span> | |
| </td> | |
| <td className="muted small">{it.detail || it.error || ""}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| )} | |
| {job.logs?.length > 0 && ( | |
| <div className="logbox mt12"> | |
| {job.logs.map((l, i) => ( | |
| <div key={i} className={l.level === "error" ? "err" : ""}>{l.msg}</div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Lớp phủ "ĐANG XỬ LÝ": hiện NGAY khi busy=true (kể cả trước khi có job), phủ kín | |
| // màn hình -> chặn mọi thao tác cho tới khi xong. Có spinner + tiến độ + bước hiện tại. | |
| export function ProcessingOverlay({ busy, job, title = "Đang xử lý…" }) { | |
| if (!busy) return null; | |
| const pct = Math.round((job?.progress || 0) * 100); | |
| const items = job?.items || []; | |
| const running = items.find((i) => i.status === "running"); | |
| const doneCount = items.filter((i) => i.status === "done").length; | |
| const lastLog = job?.logs?.length ? job.logs[job.logs.length - 1].msg : ""; | |
| // Đang xếp hàng chờ tài nguyên máy chủ (nhiều người dùng cùng lúc). | |
| const queued = job?.status === "queued" && (job?.queue_total || 0) > 0; | |
| const queueMsg = queued | |
| ? `Đang xếp hàng — vị trí ${(job.queue_pos || 0) + 1}/${job.queue_total}. Máy chủ đang bận, job của bạn sẽ tự chạy khi tới lượt.` | |
| : ""; | |
| const sub = queueMsg || running?.detail || lastLog || "Đang xử lý trên máy chủ, vui lòng đợi…"; | |
| return ( | |
| <div className="proc-overlay" role="alertdialog" aria-busy="true" aria-label={title}> | |
| <div className="proc-box"> | |
| <div className="proc-spinner" /> | |
| <div className="proc-title">{queued ? "Đang chờ tới lượt…" : title}</div> | |
| <div className="proc-sub">{sub}</div> | |
| {job && ( | |
| <> | |
| <div className="progress mt12"><i style={{ width: pct + "%" }} /></div> | |
| <div className="proc-meta"> | |
| {pct}%{items.length ? ` · ${doneCount}/${items.length} mục` : ""} · {job.status} | |
| </div> | |
| </> | |
| )} | |
| <div className="proc-hint">Thao tác bị khoá tới khi xử lý xong — đừng đóng tab.</div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Thông báo cho user biết dữ liệu được giữ tạm theo phiên (cache), không lưu lâu dài. | |
| export function CacheNote({ onClose }) { | |
| return ( | |
| <div className="cache-note"> | |
| <span className="ic"><IcInfo size={16} /></span> | |
| <span> | |
| Dữ liệu được lưu tạm theo <b>phiên làm việc</b> (cache trên trình duyệt + vùng tạm trên máy chủ), | |
| giúp bạn chuyển qua lại giữa các tool mà không mất tiến trình. Sau khi xử lý xong, hãy | |
| <b> tải kết quả về máy</b> — dữ liệu tạm sẽ tự xoá, không lưu trữ lâu dài. | |
| </span> | |
| {onClose && <button className="x" onClick={onClose} title="Đóng">×</button>} | |
| </div> | |
| ); | |
| } | |