From ce31a2702ca674fa0ca0f8f154f5b17801616dbc Mon Sep 17 00:00:00 2001 From: hienp Date: Thu, 9 Apr 2026 10:14:53 +0700 Subject: [PATCH] =?UTF-8?q?update=20ch=E1=BB=A9c=20n=C4=83ng=20g=C3=A1n=20?= =?UTF-8?q?file=20mp3=20v=C3=A0o=20trang=20pdf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/audio.py | 26 +++++- backend/app/schemas.py | 6 ++ .../src/components/FloatingAudioPlayer.tsx | 79 ++++++++++++++++-- frontend/src/components/WorkbookViewer.tsx | 83 +++++++++++++++++-- frontend/src/lib/api.ts | 11 +++ readme.md | 22 ++++- start-dev.sh | 20 ++++- 7 files changed, 231 insertions(+), 16 deletions(-) diff --git a/backend/app/routers/audio.py b/backend/app/routers/audio.py index 27866fc..bc47363 100644 --- a/backend/app/routers/audio.py +++ b/backend/app/routers/audio.py @@ -18,7 +18,7 @@ from sqlalchemy.orm import Session from ..database import get_db from ..dependencies import get_current_user, require_role from ..models import PDF, PDFAudio, UserRole -from ..schemas import AudioOut, AudioUploadResult +from ..schemas import AudioOut, AudioPatch, AudioUploadResult router = APIRouter(prefix="/pdfs", tags=["audio"]) @@ -138,6 +138,30 @@ async def upload_audio( 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} ─────────────────────────────── @router.delete("/{pdf_id}/audio/{audio_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 7718860..ee1dae9 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -111,6 +111,12 @@ class AudioOut(BaseModel): 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): filename: str success: bool diff --git a/frontend/src/components/FloatingAudioPlayer.tsx b/frontend/src/components/FloatingAudioPlayer.tsx index 2e0a894..964c8dd 100644 --- a/frontend/src/components/FloatingAudioPlayer.tsx +++ b/frontend/src/components/FloatingAudioPlayer.tsx @@ -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(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(null); + // Inline page-number editor: trackId → draft value + const [editingPage, setEditingPage] = useState<{ id: number; value: string } | null>(null); const fileInputRef = useRef(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); }} > - - {i === currentIdx ? "▶" : `${i + 1}.`} + + {i === currentIdx ? "▶" : t.id === activeTrackId ? "●" : `${i + 1}.`} {t.track_name} - {t.page_number ? ( - tr.{t.page_number} - ) : null} + {/* Page badge — click to edit (admin/teacher only) */} + {canManage && editingPage?.id === t.id ? ( + 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", + }} + /> + ) : ( + { + 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" : ""} + + )} {canManage && (