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
+25 -1
View File
@@ -18,7 +18,7 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..dependencies import get_current_user, require_role from ..dependencies import get_current_user, require_role
from ..models import PDF, PDFAudio, UserRole from ..models import PDF, PDFAudio, UserRole
from ..schemas import AudioOut, AudioUploadResult from ..schemas import AudioOut, AudioPatch, AudioUploadResult
router = APIRouter(prefix="/pdfs", tags=["audio"]) router = APIRouter(prefix="/pdfs", tags=["audio"])
@@ -138,6 +138,30 @@ async def upload_audio(
return results return results
# ── PATCH /api/pdfs/{pdf_id}/audio/{audio_id} ───────────────────────────────
@router.patch("/{pdf_id}/audio/{audio_id}", response_model=AudioOut)
def patch_audio(
pdf_id: int,
audio_id: int,
body: AudioPatch,
current_user=Depends(require_role(UserRole.admin, UserRole.teacher)),
db: Session = Depends(get_db),
):
track = db.query(PDFAudio).filter(PDFAudio.id == audio_id, PDFAudio.pdf_id == pdf_id).first()
if not track:
raise HTTPException(status_code=404, detail="Audio track not found.")
if body.track_name is not None:
track.track_name = body.track_name
if body.page_number is not None:
track.page_number = body.page_number if body.page_number > 0 else None
if body.sort_order is not None:
track.sort_order = body.sort_order
db.commit()
db.refresh(track)
return AudioOut.model_validate(track)
# ── DELETE /api/pdfs/{pdf_id}/audio/{audio_id} ─────────────────────────────── # ── DELETE /api/pdfs/{pdf_id}/audio/{audio_id} ───────────────────────────────
@router.delete("/{pdf_id}/audio/{audio_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{pdf_id}/audio/{audio_id}", status_code=status.HTTP_204_NO_CONTENT)
+6
View File
@@ -111,6 +111,12 @@ class AudioOut(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class AudioPatch(BaseModel):
track_name: str | None = None
page_number: int | None = None
sort_order: int | None = None
class AudioUploadResult(BaseModel): class AudioUploadResult(BaseModel):
filename: str filename: str
success: bool success: bool
@@ -10,6 +10,10 @@ interface Props {
/** Called by parent after a successful upload so the track list refreshes */ /** Called by parent after a successful upload so the track list refreshes */
onTracksChange: (tracks: AudioTrack[]) => void; onTracksChange: (tracks: AudioTrack[]) => void;
userRole: "admin" | "teacher" | "student"; 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 { function fmt(sec: number): string {
@@ -19,7 +23,7 @@ function fmt(sec: number): string {
return `${m}:${s.toString().padStart(2, "0")}`; 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 audioRef = useRef<HTMLAudioElement>(null);
const [currentIdx, setCurrentIdx] = useState(0); const [currentIdx, setCurrentIdx] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -28,6 +32,8 @@ export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, use
const [showPlaylist, setShowPlaylist] = useState(false); const [showPlaylist, setShowPlaylist] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<number | null>(null); 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 fileInputRef = useRef<HTMLInputElement>(null);
const canManage = userRole === "admin" || userRole === "teacher"; const canManage = userRole === "admin" || userRole === "teacher";
@@ -141,6 +147,23 @@ export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, use
[pdfId, currentTrack, onTracksChange], [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; if (!tracks.length && !canManage) return null;
return ( return (
@@ -288,22 +311,64 @@ export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, use
gap: 6, gap: 6,
padding: "4px 2px", padding: "4px 2px",
borderRadius: 6, 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", cursor: "pointer",
}} }}
onClick={() => { onClick={() => {
setCurrentIdx(i); setCurrentIdx(i);
onTrackSelect?.(t);
setShowPlaylist(false); setShowPlaylist(false);
}} }}
> >
<span style={{ fontSize: 11, color: i === currentIdx ? "#93c5fd" : "#94a3b8", minWidth: 16 }}> <span style={{ fontSize: 11, color: i === currentIdx ? "#93c5fd" : t.id === activeTrackId ? "#fbbf24" : "#94a3b8", minWidth: 16 }}>
{i === currentIdx ? "▶" : `${i + 1}.`} {i === currentIdx ? "▶" : t.id === activeTrackId ? "●" : `${i + 1}.`}
</span> </span>
<span style={{ fontSize: 12, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ fontSize: 12, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t.track_name} {t.track_name}
{t.page_number ? ( {/* Page badge — click to edit (admin/teacher only) */}
<span style={{ marginLeft: 4, fontSize: 10, color: "#64748b" }}>tr.{t.page_number}</span> {canManage && editingPage?.id === t.id ? (
) : null} <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> </span>
{canManage && ( {canManage && (
<button <button
+77 -6
View File
@@ -132,6 +132,10 @@ export default function WorkbookViewer({ pdfId }: Props) {
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]); const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [showAudio, setShowAudio] = useState(false); 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 [pdfTitle, setPdfTitle] = useState("PDF");
const [loadError, setLoadError] = useState(""); const [loadError, setLoadError] = useState("");
const [rendering, setRendering] = useState(false); const [rendering, setRendering] = useState(false);
@@ -195,6 +199,41 @@ export default function WorkbookViewer({ pdfId }: Props) {
useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]); useEffect(() => { scrollModeRef.current = scrollMode; }, [scrollMode]);
useEffect(() => { currentUsernameRef.current = currentUsername; }, [currentUsername]); 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 // Keep syncTempCanvasRef current so closures inside Fabric events always see latest pdfId
useEffect(() => { useEffect(() => {
syncTempCanvasRef.current = (page: number) => { syncTempCanvasRef.current = (page: number) => {
@@ -223,6 +262,16 @@ export default function WorkbookViewer({ pdfId }: Props) {
} }
}, [currentPage, showThumbnails]); }, [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) // Fetch JWT token for WebSocket auth (cookie not sent on WS upgrade)
useEffect(() => { useEffect(() => {
fetch("/api/auth/token", { credentials: "include" }) fetch("/api/auth/token", { credentials: "include" })
@@ -474,7 +523,13 @@ export default function WorkbookViewer({ pdfId }: Props) {
viewport, viewport,
}); });
renderTasksRef.current.push(singleRenderTask); 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 // ── 2. Resize Fabric canvas to match
fc.setWidth(pdfCvs.width); fc.setWidth(pdfCvs.width);
fc.setHeight(pdfCvs.height); fc.setHeight(pdfCvs.height);
@@ -1103,6 +1158,20 @@ export default function WorkbookViewer({ pdfId }: Props) {
[currentPage, totalPages, rendering, scrollMode, renderPageWithAnnotations] [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 // 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="h-5 w-px bg-gray-200 flex-shrink-0" />
<div className="flex items-center gap-0.5 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="select" current={tool} onClick={setTool} title="Select / Move [S]" iconClass="fa-solid fa-arrow-pointer" />
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen" iconClass="fa-solid fa-pen" /> <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" iconClass="fa-solid fa-highlighter" /> <ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter [H]" iconClass="fa-solid fa-highlighter" />
<ToolBtn id="text" current={tool} onClick={setTool} title="Text" iconClass="fa-solid fa-font" /> <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" iconClass="fa-solid fa-eraser" /> <ToolBtn id="eraser" current={tool} onClick={setTool} title="Eraser [E]" iconClass="fa-solid fa-eraser" />
</div> </div>
{/* Color picker button */} {/* Color picker button */}
@@ -1753,6 +1822,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
tracks={audioTracks} tracks={audioTracks}
onTracksChange={setAudioTracks} onTracksChange={setAudioTracks}
userRole={userRole} userRole={userRole}
activeTrackId={activeTrackId}
onTrackSelect={handleTrackSelect}
/> />
)} )}
+11
View File
@@ -131,6 +131,17 @@ export const api = {
deleteAudio: (pdfId: number, audioId: number) => deleteAudio: (pdfId: number, audioId: number) =>
request<void>(`/api/pdfs/${pdfId}/audio/${audioId}`, { method: "DELETE" }), 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) => audioFileUrl: (pdfId: number, audioId: number) =>
`/api/pdfs/${pdfId}/audio/${audioId}/file`, `/api/pdfs/${pdfId}/audio/${audioId}/file`,
}; };
+21 -1
View File
@@ -149,4 +149,24 @@ Lệnh Kết quả tag
./push-ghcr.sh major 1.0.0 → 2.0.0 ./push-ghcr.sh major 1.0.0 → 2.0.0
./push-ghcr.sh 1.5.0 đúng 1.5.0, không auto-bump ./push-ghcr.sh 1.5.0 đúng 1.5.0, không auto-bump
./push-ghcr.sh --no-cache rebuild + auto-bump patch ./push-ghcr.sh --no-cache rebuild + auto-bump patch
./push-ghcr.sh minor --no-cache bump minor + rebuild từ đầu ./push-ghcr.sh minor --no-cache bump minor + rebuild từ đầu
Phím Công cụ Dùng cho
H Highlighter Đánh dấu từ vựng mới
T Text Box Điền đáp án
P hoặc F Pen (Freehand) Nối các ý trong bài ngữ pháp
S Select / Move Chọn & di chuyển
E Eraser Tẩy
Cách sử dụng
Admin/Teacher mở playlist (nhấn ☰) → mỗi track hiển thị badge page:
Trạng thái Hiển thị
Đã có trang tr.20 (màu xanh)
Chưa có trang + trang (gạch chân đứt)
Nhấn vào badge → ô input số xuất hiện inline → nhập số trang → nhấn Enter hoặc click ra ngoài để lưu. Nhấn Escape để huỷ.
Kết quả: Sau khi lưu, sync hai chiều Audio↔PDF hoạt động ngay vì page_number đã có giá trị.
Backend mới: PATCH /api/pdfs/{pdf_id}/audio/{audio_id} — nhận
+19 -1
View File
@@ -27,7 +27,7 @@ JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set in .env}"
FRONTEND_PORT="${FRONTEND_PORT:-3011}" FRONTEND_PORT="${FRONTEND_PORT:-3011}"
BACKEND_PORT="${BACKEND_PORT:-8001}" BACKEND_PORT="${BACKEND_PORT:-8001}"
DATABASE_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5433/${POSTGRES_DB}" DATABASE_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}"
UPLOAD_DIR="./backend/uploads" UPLOAD_DIR="./backend/uploads"
# ── Colors ──────────────────────────────────────────────────────────────────── # ── Colors ────────────────────────────────────────────────────────────────────
@@ -40,6 +40,15 @@ TARGET="${1:-both}"
start_backend() { start_backend() {
echo -e "${CYAN}${BOLD}▶ Starting Backend${NC} (port ${BACKEND_PORT})" echo -e "${CYAN}${BOLD}▶ Starting Backend${NC} (port ${BACKEND_PORT})"
# Kill any process already occupying the backend port
local old_pid
old_pid=$(lsof -ti tcp:"$BACKEND_PORT" 2>/dev/null || true)
if [ -n "$old_pid" ]; then
echo -e "${YELLOW} Killing process on port ${BACKEND_PORT} (PID ${old_pid})...${NC}"
kill -9 $old_pid 2>/dev/null || true
sleep 0.5
fi
# Check Python venv # Check Python venv
VENV="" VENV=""
if [ -d backend/.venv ]; then if [ -d backend/.venv ]; then
@@ -78,6 +87,15 @@ start_backend() {
start_frontend() { start_frontend() {
echo -e "${CYAN}${BOLD}▶ Starting Frontend${NC} (port ${FRONTEND_PORT})" echo -e "${CYAN}${BOLD}▶ Starting Frontend${NC} (port ${FRONTEND_PORT})"
# Kill any process already occupying the frontend port
local old_pid
old_pid=$(lsof -ti tcp:"$FRONTEND_PORT" 2>/dev/null || true)
if [ -n "$old_pid" ]; then
echo -e "${YELLOW} Killing process on port ${FRONTEND_PORT} (PID ${old_pid})...${NC}"
kill -9 $old_pid 2>/dev/null || true
sleep 0.5
fi
if [ ! -d frontend/node_modules ]; then if [ ! -d frontend/node_modules ]; then
echo -e "${YELLOW} node_modules not found — running npm install...${NC}" echo -e "${YELLOW} node_modules not found — running npm install...${NC}"
(cd frontend && npm install) (cd frontend && npm install)