mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 09:03:11 +07:00
update chức năng gán file mp3 vào trang pdf
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
|
||||
@@ -149,4 +149,24 @@ Lệnh Kết quả tag
|
||||
./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 --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
@@ -27,7 +27,7 @@ JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set in .env}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-3011}"
|
||||
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"
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
@@ -40,6 +40,15 @@ TARGET="${1:-both}"
|
||||
start_backend() {
|
||||
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
|
||||
VENV=""
|
||||
if [ -d backend/.venv ]; then
|
||||
@@ -78,6 +87,15 @@ start_backend() {
|
||||
start_frontend() {
|
||||
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
|
||||
echo -e "${YELLOW} node_modules not found — running npm install...${NC}"
|
||||
(cd frontend && npm install)
|
||||
|
||||
Reference in New Issue
Block a user