update chức năng gán file mp3 vào trang pdf

This commit is contained in:
2026-04-09 10:14:53 +07:00
parent faab7fec7d
commit ce31a2702c
7 changed files with 231 additions and 16 deletions
@@ -10,6 +10,10 @@ interface Props {
/** Called by parent after a successful upload so the track list refreshes */
onTracksChange: (tracks: AudioTrack[]) => void;
userRole: "admin" | "teacher" | "student";
/** ID of the track cued by page-sync (may differ from currently playing) */
activeTrackId?: number | null;
/** Called when user manually clicks a track — parent scrolls PDF to its page */
onTrackSelect?: (track: AudioTrack) => void;
}
function fmt(sec: number): string {
@@ -19,7 +23,7 @@ function fmt(sec: number): string {
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, userRole }: Props) {
export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, userRole, activeTrackId, onTrackSelect }: Props) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentIdx, setCurrentIdx] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
@@ -28,6 +32,8 @@ export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, use
const [showPlaylist, setShowPlaylist] = useState(false);
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<number | null>(null);
// Inline page-number editor: trackId → draft value
const [editingPage, setEditingPage] = useState<{ id: number; value: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const canManage = userRole === "admin" || userRole === "teacher";
@@ -141,6 +147,23 @@ export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, use
[pdfId, currentTrack, onTracksChange],
);
const commitPageEdit = useCallback(
async (trackId: number, raw: string) => {
setEditingPage(null);
const page = parseInt(raw, 10);
// 0 or NaN → clear mapping; otherwise set the page
const pageNumber = isNaN(page) || page <= 0 ? null : page;
try {
await api.updateAudio(pdfId, trackId, { page_number: pageNumber ?? 0 });
const updated = await api.listAudio(pdfId);
onTracksChange(updated);
} catch (err) {
alert(err instanceof Error ? err.message : "Update failed.");
}
},
[pdfId, onTracksChange],
);
if (!tracks.length && !canManage) return null;
return (
@@ -288,22 +311,64 @@ export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, use
gap: 6,
padding: "4px 2px",
borderRadius: 6,
background: i === currentIdx ? "rgba(59,130,246,0.18)" : "transparent",
background:
i === currentIdx
? "rgba(59,130,246,0.18)"
: t.id === activeTrackId
? "rgba(234,179,8,0.13)"
: "transparent",
cursor: "pointer",
}}
onClick={() => {
setCurrentIdx(i);
onTrackSelect?.(t);
setShowPlaylist(false);
}}
>
<span style={{ fontSize: 11, color: i === currentIdx ? "#93c5fd" : "#94a3b8", minWidth: 16 }}>
{i === currentIdx ? "▶" : `${i + 1}.`}
<span style={{ fontSize: 11, color: i === currentIdx ? "#93c5fd" : t.id === activeTrackId ? "#fbbf24" : "#94a3b8", minWidth: 16 }}>
{i === currentIdx ? "▶" : t.id === activeTrackId ? "●" : `${i + 1}.`}
</span>
<span style={{ fontSize: 12, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t.track_name}
{t.page_number ? (
<span style={{ marginLeft: 4, fontSize: 10, color: "#64748b" }}>tr.{t.page_number}</span>
) : null}
{/* Page badge — click to edit (admin/teacher only) */}
{canManage && editingPage?.id === t.id ? (
<input
autoFocus
type="number"
min={0}
value={editingPage.value}
onChange={(e) => setEditingPage({ id: t.id, value: e.target.value })}
onBlur={() => commitPageEdit(t.id, editingPage.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitPageEdit(t.id, editingPage.value);
if (e.key === "Escape") setEditingPage(null);
}}
onClick={(e) => e.stopPropagation()}
style={{
marginLeft: 4, width: 46, fontSize: 10,
background: "#1e293b", color: "#e2e8f0",
border: "1px solid #3b82f6", borderRadius: 3,
padding: "1px 3px",
}}
/>
) : (
<span
title={canManage ? "Nhấn để gán trang" : undefined}
onClick={(e) => {
if (!canManage) return;
e.stopPropagation();
setEditingPage({ id: t.id, value: String(t.page_number ?? "") });
}}
style={{
marginLeft: 4, fontSize: 10,
color: t.page_number ? "#38bdf8" : "#475569",
cursor: canManage ? "text" : "default",
borderBottom: canManage ? "1px dashed #475569" : "none",
}}
>
{t.page_number ? `tr.${t.page_number}` : canManage ? "+ trang" : ""}
</span>
)}
</span>
{canManage && (
<button
+77 -6
View File
@@ -132,6 +132,10 @@ export default function WorkbookViewer({ pdfId }: Props) {
const [isReady, setIsReady] = useState(false);
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [showAudio, setShowAudio] = useState(false);
// Two-way sync: ID of the track cued/highlighted by page-sync (may differ from playing track)
const [activeTrackId, setActiveTrackId] = useState<number | null>(null);
// Prevents the infinite loop: Audio→PDF sets true, PDF→Audio checks this guard
const isAutoScrolling = useRef(false);
const [pdfTitle, setPdfTitle] = useState("PDF");
const [loadError, setLoadError] = useState("");
const [rendering, setRendering] = useState(false);
@@ -195,6 +199,41 @@ export default function WorkbookViewer({ pdfId }: Props) {
useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]);
useEffect(() => { currentUsernameRef.current = currentUsername; }, [currentUsername]);
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
// H → Highlighter | T → Text box | P/F → Pen (freehand)
// S → Select | E → Eraser
// Shortcuts are ignored when focus is inside an input/textarea/contenteditable.
useEffect(() => {
const TOOL_MAP: Record<string, Tool> = {
h: "highlighter",
t: "text",
p: "pen",
f: "pen",
s: "select",
e: "eraser",
};
const handler = (ev: KeyboardEvent) => {
// Don't fire when the user is actually typing in a field
const target = ev.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
ev.metaKey ||
ev.ctrlKey ||
ev.altKey
) return;
const mapped = TOOL_MAP[ev.key.toLowerCase()];
if (!mapped) return;
ev.preventDefault();
// Auto-enter draw mode so the shortcut is immediately usable
setViewMode("draw");
setTool(mapped);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId
useEffect(() => {
syncTempCanvasRef.current = (page: number) => {
@@ -223,6 +262,16 @@ export default function WorkbookViewer({ pdfId }: Props) {
}
}, [currentPage, showThumbnails]);
// ── Logic B: PDF → Audio ──────────────────────────────────────────────────
// When the visible page changes, highlight the matching audio track.
// Guard: skip if this scroll was triggered BY a track click (isAutoScrolling).
useEffect(() => {
if (isAutoScrolling.current) return;
if (!audioTracks.length) return;
const match = audioTracks.find((t) => t.page_number === currentPage);
setActiveTrackId(match ? match.id : null);
}, [currentPage, audioTracks]);
// Fetch JWT token for WebSocket auth (cookie not sent on WS upgrade)
useEffect(() => {
fetch("/api/auth/token", { credentials: "include" })
@@ -474,7 +523,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
viewport,
});
renderTasksRef.current.push(singleRenderTask);
await singleRenderTask.promise;
try {
await singleRenderTask.promise;
} catch (e: unknown) {
// Normal when the user navigates away before rendering finishes
if ((e as { name?: string })?.name === "RenderingCancelledException") return;
throw e;
}
// ── 2. Resize Fabric canvas to match
fc.setWidth(pdfCvs.width);
fc.setHeight(pdfCvs.height);
@@ -1103,6 +1158,20 @@ export default function WorkbookViewer({ pdfId }: Props) {
[currentPage, totalPages, rendering, scrollMode, renderPageWithAnnotations]
);
// ── Logic A: Audio → PDF ──────────────────────────────────────────────────
// When a track is clicked in the playlist, scroll the PDF to its target page.
const handleTrackSelect = useCallback(
async (track: AudioTrack) => {
if (!track.page_number) return;
isAutoScrolling.current = true;
setActiveTrackId(track.id);
await goToPage(track.page_number);
// Small buffer for continuous-scroll animation to finish
setTimeout(() => { isAutoScrolling.current = false; }, 400);
},
[goToPage]
);
// ─────────────────────────────────────────────────────────────────────────
// Export annotated PDF — renders every page with fabric annotations baked in
// ─────────────────────────────────────────────────────────────────────────
@@ -1476,11 +1545,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
<div className="h-5 w-px bg-gray-200 flex-shrink-0" />
<div className="flex items-center gap-0.5 flex-shrink-0">
<ToolBtn id="select" current={tool} onClick={setTool} title="Select / Move" iconClass="fa-solid fa-arrow-pointer" />
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen" iconClass="fa-solid fa-pen" />
<ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter" iconClass="fa-solid fa-highlighter" />
<ToolBtn id="text" current={tool} onClick={setTool} title="Text" iconClass="fa-solid fa-font" />
<ToolBtn id="eraser" current={tool} onClick={setTool} title="Eraser" iconClass="fa-solid fa-eraser" />
<ToolBtn id="select" current={tool} onClick={setTool} title="Select / Move [S]" iconClass="fa-solid fa-arrow-pointer" />
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen Freehand [P / F]" iconClass="fa-solid fa-pen" />
<ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter [H]" iconClass="fa-solid fa-highlighter" />
<ToolBtn id="text" current={tool} onClick={setTool} title="Text Box [T]" iconClass="fa-solid fa-font" />
<ToolBtn id="eraser" current={tool} onClick={setTool} title="Eraser [E]" iconClass="fa-solid fa-eraser" />
</div>
{/* Color picker button */}
@@ -1753,6 +1822,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
tracks={audioTracks}
onTracksChange={setAudioTracks}
userRole={userRole}
activeTrackId={activeTrackId}
onTrackSelect={handleTrackSelect}
/>
)}
+11
View File
@@ -131,6 +131,17 @@ export const api = {
deleteAudio: (pdfId: number, audioId: number) =>
request<void>(`/api/pdfs/${pdfId}/audio/${audioId}`, { method: "DELETE" }),
updateAudio: (
pdfId: number,
audioId: number,
body: { track_name?: string; page_number?: number | null; sort_order?: number },
) =>
request<import("@/types").AudioTrack>(`/api/pdfs/${pdfId}/audio/${audioId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
audioFileUrl: (pdfId: number, audioId: number) =>
`/api/pdfs/${pdfId}/audio/${audioId}/file`,
};