Merge pull request 'dev03' (#4) from dev03 into main

Reviewed-on: basketballcantho/ten-project#4
This commit is contained in:
2026-04-03 00:52:37 +00:00
13 changed files with 732 additions and 29 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.5
1.0.7
@@ -0,0 +1,35 @@
"""add pdf_audios table
Revision ID: 0003_add_pdf_audios
Revises: 0002_add_role_status
Create Date: 2026-04-02
"""
from alembic import op
import sqlalchemy as sa
revision = "0003_add_pdf_audios"
down_revision = "0002_add_role_status"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"pdf_audios",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("pdf_id", sa.Integer(), sa.ForeignKey("pdfs.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("file_path", sa.Text(), nullable=False),
sa.Column("track_name", sa.String(255), nullable=False),
sa.Column("page_number", sa.Integer(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("pdf_audios")
+2 -1
View File
@@ -4,7 +4,7 @@ import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import admin, annotations, auth, backup, pdfs, ws
from .routers import admin, annotations, audio, auth, backup, pdfs, ws
from .database import SessionLocal
from .models import User, UserRole, UserStatus
from .security import hash_password
@@ -57,5 +57,6 @@ app.include_router(auth.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(backup.router, prefix="/api")
app.include_router(pdfs.router, prefix="/api")
app.include_router(audio.router, prefix="/api")
app.include_router(annotations.router, prefix="/api")
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
+17
View File
@@ -10,6 +10,7 @@ from sqlalchemy import (
ForeignKey,
DateTime,
UniqueConstraint,
Boolean,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
@@ -57,6 +58,7 @@ class PDF(Base):
# Relationships
owner = relationship("User", back_populates="pdfs")
annotations = relationship("Annotation", back_populates="pdf", cascade="all, delete-orphan")
audio_tracks = relationship("PDFAudio", back_populates="pdf", cascade="all, delete-orphan")
class Annotation(Base):
@@ -81,3 +83,18 @@ class Annotation(Base):
# Relationships
pdf = relationship("PDF", back_populates="annotations")
owner = relationship("User", back_populates="annotations")
class PDFAudio(Base):
__tablename__ = "pdf_audios"
id = Column(Integer, primary_key=True, index=True)
pdf_id = Column(Integer, ForeignKey("pdfs.id", ondelete="CASCADE"), nullable=False, index=True)
file_path = Column(Text, nullable=False)
track_name = Column(String(255), nullable=False)
page_number = Column(Integer, nullable=True) # optional: link to a specific page
sort_order = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
# Relationships
pdf = relationship("PDF", back_populates="audio_tracks")
+185
View File
@@ -0,0 +1,185 @@
"""
Audio management endpoints.
POST /api/pdfs/{pdf_id}/audio — upload one or more MP3 files (admin/teacher only)
GET /api/pdfs/{pdf_id}/audio — list all audio tracks for a PDF (any authenticated user)
DELETE /api/pdfs/{pdf_id}/audio/{audio_id} — delete a single track (admin/teacher only)
GET /api/pdfs/{pdf_id}/audio/{audio_id}/file — stream the audio file
"""
import os
import re
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
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
router = APIRouter(prefix="/pdfs", tags=["audio"])
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/uploads"))
MAX_AUDIO_SIZE = 200 * 1024 * 1024 # 200 MB per file
ALLOWED_AUDIO_TYPES = {"audio/mpeg", "audio/mp3", "audio/mp4", "audio/x-m4a", "audio/ogg", "application/octet-stream"}
def _audio_dir(pdf_id: int) -> Path:
d = UPLOAD_DIR / "audio" / str(pdf_id)
d.mkdir(parents=True, exist_ok=True)
return d
def _safe_track_name(raw: str) -> str:
"""Strip control characters; truncate to 255 chars."""
cleaned = re.sub(r"[\x00-\x1f\x7f]", "", raw).strip()
return cleaned[:255] or "Track"
# ── GET /api/pdfs/{pdf_id}/audio ──────────────────────────────────────────────
@router.get("/{pdf_id}/audio", response_model=list[AudioOut])
def list_audio(
pdf_id: int,
current_user=Depends(get_current_user),
db: Session = Depends(get_db),
):
pdf = db.get(PDF, pdf_id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found.")
tracks = (
db.query(PDFAudio)
.filter(PDFAudio.pdf_id == pdf_id)
.order_by(PDFAudio.sort_order, PDFAudio.id)
.all()
)
return tracks
# ── POST /api/pdfs/{pdf_id}/audio ────────────────────────────────────────────
@router.post(
"/{pdf_id}/audio",
response_model=list[AudioUploadResult],
status_code=status.HTTP_201_CREATED,
)
async def upload_audio(
pdf_id: int,
files: list[UploadFile],
track_names: list[str] = Form(default=[]),
page_numbers: list[int] = Form(default=[]),
sort_orders: list[int] = Form(default=[]),
current_user=Depends(require_role(UserRole.admin, UserRole.teacher)),
db: Session = Depends(get_db),
):
"""
Upload one or more audio files for a PDF.
Optional form fields (parallel arrays, indexed per file):
- track_names: display name for each track
- page_numbers: page the track belongs to (0 = whole PDF / unset)
- sort_orders: ordering integer (default 1)
"""
pdf = db.get(PDF, pdf_id)
if not pdf:
raise HTTPException(status_code=404, detail="PDF not found.")
results: list[AudioUploadResult] = []
for idx, file in enumerate(files):
fname = file.filename or f"audio_{idx + 1}"
content_type = (file.content_type or "").lower()
if content_type not in ALLOWED_AUDIO_TYPES:
# Accept based on extension if browser sends wrong MIME
ext = Path(fname).suffix.lower()
if ext not in {".mp3", ".m4a", ".ogg", ".wav", ".aac"}:
results.append(AudioUploadResult(filename=fname, success=False, error="Unsupported audio format."))
continue
body = await file.read()
if len(body) > MAX_AUDIO_SIZE:
results.append(AudioUploadResult(filename=fname, success=False, error="File exceeds 200 MB limit."))
continue
safe_name = f"{uuid.uuid4()}{Path(fname).suffix or '.mp3'}"
dest = _audio_dir(pdf_id) / safe_name
dest.write_bytes(body)
# Derive metadata from parallel arrays (fall back to sensible defaults)
raw_track_name = track_names[idx] if idx < len(track_names) else Path(fname).stem
track_name = _safe_track_name(raw_track_name) or Path(fname).stem or f"Track {idx + 1}"
page_num = page_numbers[idx] if idx < len(page_numbers) else None
if page_num == 0:
page_num = None
sort_ord = sort_orders[idx] if idx < len(sort_orders) else (idx + 1)
# Determine next sort order if not supplied
if not sort_orders:
existing_count = db.query(PDFAudio).filter(PDFAudio.pdf_id == pdf_id).count()
sort_ord = existing_count + idx + 1
audio = PDFAudio(
pdf_id=pdf_id,
file_path=str(dest),
track_name=track_name,
page_number=page_num,
sort_order=sort_ord,
)
db.add(audio)
db.commit()
db.refresh(audio)
results.append(AudioUploadResult(filename=fname, success=True, audio=AudioOut.model_validate(audio)))
return results
# ── DELETE /api/pdfs/{pdf_id}/audio/{audio_id} ───────────────────────────────
@router.delete("/{pdf_id}/audio/{audio_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_audio(
pdf_id: int,
audio_id: int,
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.")
file_path = Path(track.file_path)
db.delete(track)
db.commit()
if file_path.exists():
file_path.unlink(missing_ok=True)
# ── GET /api/pdfs/{pdf_id}/audio/{audio_id}/file ─────────────────────────────
@router.get("/{pdf_id}/audio/{audio_id}/file")
def serve_audio(
pdf_id: int,
audio_id: int,
current_user=Depends(get_current_user),
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.")
file_path = Path(track.file_path)
if not file_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found on disk.")
# Determine media type from extension
ext = file_path.suffix.lower()
media_type_map = {".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".ogg": "audio/ogg", ".wav": "audio/wav", ".aac": "audio/aac"}
media_type = media_type_map.get(ext, "audio/mpeg")
return FileResponse(
path=str(file_path),
media_type=media_type,
filename=f"{track.track_name}{ext}",
)
+20
View File
@@ -98,6 +98,26 @@ class AnnotationIn(BaseModel):
canvas_data: dict
# ── Audio ─────────────────────────────────────────────────────────────────────
class AudioOut(BaseModel):
id: int
pdf_id: int
track_name: str
page_number: int | None
sort_order: int
created_at: datetime
model_config = {"from_attributes": True}
class AudioUploadResult(BaseModel):
filename: str
success: bool
audio: AudioOut | None = None
error: str | None = None
class AnnotationOut(BaseModel):
id: int
pdf_id: int
+2
View File
@@ -10,6 +10,8 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lms_dev_password}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms_user} -d ${POSTGRES_DB:-lms_db}"]
interval: 5s
+8
View File
@@ -9,6 +9,14 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
crossOrigin="anonymous"
referrerPolicy="no-referrer"
/>
</head>
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
</html>
);
@@ -0,0 +1,366 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AudioTrack } from "@/types";
import { api } from "@/lib/api";
interface Props {
pdfId: number;
tracks: AudioTrack[];
/** Called by parent after a successful upload so the track list refreshes */
onTracksChange: (tracks: AudioTrack[]) => void;
userRole: "admin" | "teacher" | "student";
}
function fmt(sec: number): string {
if (!isFinite(sec)) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function FloatingAudioPlayer({ pdfId, tracks, onTracksChange, userRole }: Props) {
const audioRef = useRef<HTMLAudioElement>(null);
const [currentIdx, setCurrentIdx] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showPlaylist, setShowPlaylist] = useState(false);
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const canManage = userRole === "admin" || userRole === "teacher";
const multiTrack = tracks.length > 1;
const currentTrack = tracks[currentIdx] ?? null;
const srcUrl = currentTrack ? api.audioFileUrl(pdfId, currentTrack.id) : null;
// Sync audio element src when track changes
useEffect(() => {
const audio = audioRef.current;
if (!audio || !srcUrl) return;
audio.pause();
audio.load();
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
}, [srcUrl]);
const togglePlay = useCallback(async () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
} else {
await audio.play().catch(() => {});
}
}, [isPlaying]);
const rewind5 = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = Math.max(0, audio.currentTime - 5);
}, []);
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = Number(e.target.value);
}, []);
const handleTimeUpdate = useCallback(() => {
setCurrentTime(audioRef.current?.currentTime ?? 0);
}, []);
const handleLoadedMetadata = useCallback(() => {
setDuration(audioRef.current?.duration ?? 0);
}, []);
const handleEnded = useCallback(() => {
if (multiTrack && currentIdx < tracks.length - 1) {
setCurrentIdx((i) => i + 1);
} else {
setIsPlaying(false);
}
}, [multiTrack, currentIdx, tracks.length]);
// Auto-play next track when index changes
useEffect(() => {
if (isPlaying) {
audioRef.current?.play().catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentIdx]);
// File upload handler
const handleFileUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
try {
const meta = files.map((f, i) => ({
trackName: f.name.replace(/\.[^.]+$/, ""),
sortOrder: tracks.length + i + 1,
}));
const results = await api.uploadAudio(pdfId, files, meta);
const newTracks = results.filter((r) => r.success && r.audio).map((r) => r.audio!);
if (newTracks.length) {
const updated = await api.listAudio(pdfId);
onTracksChange(updated);
}
const errors = results.filter((r) => !r.success);
if (errors.length) {
alert(errors.map((r) => `${r.filename}: ${r.error}`).join("\n"));
}
} catch (err) {
alert(err instanceof Error ? err.message : "Upload failed.");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
},
[pdfId, tracks.length, onTracksChange],
);
const handleDelete = useCallback(
async (trackId: number) => {
if (!confirm("Xóa track này?")) return;
setDeleting(trackId);
try {
await api.deleteAudio(pdfId, trackId);
const updated = await api.listAudio(pdfId);
onTracksChange(updated);
if (currentTrack?.id === trackId) setCurrentIdx(0);
} catch (err) {
alert(err instanceof Error ? err.message : "Delete failed.");
} finally {
setDeleting(null);
}
},
[pdfId, currentTrack, onTracksChange],
);
if (!tracks.length && !canManage) return null;
return (
<div
style={{
position: "fixed",
bottom: 24,
right: 24,
zIndex: 1000,
width: 340,
background: "rgba(15, 23, 42, 0.96)",
color: "#e2e8f0",
borderRadius: 14,
boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
padding: "12px 14px 10px",
backdropFilter: "blur(8px)",
userSelect: "none",
}}
>
{/* Hidden native audio element */}
{srcUrl && (
<audio
ref={audioRef}
src={srcUrl}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
preload="metadata"
/>
)}
{/* Track name row */}
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{currentTrack ? currentTrack.track_name : "Chưa có audio"}
</span>
{multiTrack && (
<button
title="Playlist"
onClick={() => setShowPlaylist((v) => !v)}
style={iconBtn(showPlaylist ? "#3b82f6" : "#64748b")}
>
</button>
)}
{canManage && (
<>
<button
title="Tải lên audio"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={iconBtn("#22c55e")}
>
{uploading ? "…" : "+"}
</button>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
multiple
style={{ display: "none" }}
onChange={handleFileUpload}
/>
</>
)}
</div>
{/* Timeline */}
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
<span style={{ fontSize: 10, minWidth: 32, textAlign: "right", color: "#94a3b8" }}>{fmt(currentTime)}</span>
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onChange={handleSeek}
disabled={!srcUrl}
style={{ flex: 1, accentColor: "#3b82f6", cursor: srcUrl ? "pointer" : "not-allowed" }}
/>
<span style={{ fontSize: 10, minWidth: 32, color: "#94a3b8" }}>{fmt(duration)}</span>
</div>
{/* Controls */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10 }}>
{multiTrack && (
<button
title="Bài trước"
onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}
disabled={currentIdx === 0}
style={ctrlBtn(currentIdx === 0)}
>
</button>
)}
<button title="Tua lại 5 giây" onClick={rewind5} disabled={!srcUrl} style={ctrlBtn(!srcUrl)}>
5
</button>
<button
title={isPlaying ? "Tạm dừng" : "Phát"}
onClick={togglePlay}
disabled={!srcUrl}
style={{
...ctrlBtn(!srcUrl),
width: 40,
height: 40,
borderRadius: "50%",
background: srcUrl ? "#3b82f6" : "#475569",
fontSize: 18,
}}
>
{isPlaying ? "⏸" : "▶"}
</button>
{multiTrack && (
<button
title="Bài tiếp"
onClick={() => setCurrentIdx((i) => Math.min(tracks.length - 1, i + 1))}
disabled={currentIdx === tracks.length - 1}
style={ctrlBtn(currentIdx === tracks.length - 1)}
>
</button>
)}
</div>
{/* Playlist drawer */}
{showPlaylist && multiTrack && (
<div
style={{
marginTop: 10,
maxHeight: 200,
overflowY: "auto",
borderTop: "1px solid #1e293b",
paddingTop: 8,
}}
>
{tracks.map((t, i) => (
<div
key={t.id}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "4px 2px",
borderRadius: 6,
background: i === currentIdx ? "rgba(59,130,246,0.18)" : "transparent",
cursor: "pointer",
}}
onClick={() => {
setCurrentIdx(i);
setShowPlaylist(false);
}}
>
<span style={{ fontSize: 11, color: i === currentIdx ? "#93c5fd" : "#94a3b8", minWidth: 16 }}>
{i === currentIdx ? "▶" : `${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}
</span>
{canManage && (
<button
title="Xóa"
onClick={(e) => { e.stopPropagation(); handleDelete(t.id); }}
disabled={deleting === t.id}
style={{ background: "none", border: "none", color: "#f87171", cursor: "pointer", fontSize: 13, padding: "0 2px" }}
>
</button>
)}
</div>
))}
</div>
)}
{/* Single track delete for admins/teachers */}
{!multiTrack && currentTrack && canManage && (
<div style={{ marginTop: 8, textAlign: "right" }}>
<button
onClick={() => handleDelete(currentTrack.id)}
disabled={deleting === currentTrack.id}
style={{ background: "none", border: "none", color: "#f87171", cursor: "pointer", fontSize: 11 }}
>
Xóa track
</button>
</div>
)}
</div>
);
}
// ── Style helpers ─────────────────────────────────────────────────────────────
function iconBtn(color: string): React.CSSProperties {
return {
background: "none",
border: "none",
color,
cursor: "pointer",
fontSize: 16,
padding: "2px 4px",
lineHeight: 1,
};
}
function ctrlBtn(disabled: boolean): React.CSSProperties {
return {
background: "none",
border: "none",
color: disabled ? "#475569" : "#e2e8f0",
cursor: disabled ? "not-allowed" : "pointer",
fontSize: 20,
padding: "2px 4px",
lineHeight: 1,
width: 32,
height: 32,
borderRadius: 6,
};
}
+45 -24
View File
@@ -5,6 +5,8 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration";
import FloatingAudioPlayer from "./FloatingAudioPlayer";
import type { AudioTrack } from "@/types";
// ─────────────────────────────────────────────────────────────────────────────
// Preset color palette (30 hues, 3 brightness levels × 10 hue families)
@@ -63,13 +65,13 @@ function ToolBtn({
current,
onClick,
title,
children,
iconClass,
}: {
id: Tool;
current: Tool;
onClick: (t: Tool) => void;
title: string;
children: React.ReactNode;
iconClass: string;
}) {
return (
<button
@@ -81,9 +83,7 @@ function ToolBtn({
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800"
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{children}
</svg>
<i className={`${iconClass} w-4 text-center`} style={{ fontSize: 15 }} />
</button>
);
}
@@ -130,6 +130,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
const [saveNotice, setSaveNotice] = useState(false);
const [exporting, setExporting] = useState(false);
const [isReady, setIsReady] = useState(false);
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [showAudio, setShowAudio] = useState(false);
const [pdfTitle, setPdfTitle] = useState("PDF");
const [loadError, setLoadError] = useState("");
const [rendering, setRendering] = useState(false);
@@ -246,6 +248,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
}).catch(() => {});
}, []);
// Fetch audio tracks for this PDF
useEffect(() => {
api.listAudio(pdfId).then(setAudioTracks).catch(() => {});
}, [pdfId]);
// Close color picker when clicking outside
useEffect(() => {
if (!colorPickerOpen) return;
@@ -1469,25 +1476,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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
</ToolBtn>
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</ToolBtn>
<ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</ToolBtn>
<ToolBtn id="text" current={tool} onClick={setTool} title="Text">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12h6m-3-3v6M7 20h10a2 2 0 002-2V6a2 2 0 00-2-2H7a2 2 0 00-2 2v12a2 2 0 002 2z" />
</ToolBtn>
<ToolBtn id="eraser" current={tool} onClick={setTool} title="Eraser">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 7l-1.5 1.5M4.5 19.5l9-9m0 0L9 6l-4.5 4.5 4.5 4.5m4.5-4.5L18 6" />
</ToolBtn>
<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" />
</div>
{/* Color picker button */}
@@ -1609,6 +1602,25 @@ export default function WorkbookViewer({ pdfId }: Props) {
{saveNotice && (
<span className="text-xs text-green-600 font-medium animate-pulse"> Saved</span>
)}
{/* Audio toggle button */}
{(audioTracks.length > 0 || userRole === "admin" || userRole === "teacher") && (
<button
onClick={() => setShowAudio(v => !v)}
title={showAudio ? "Ẩn trình phát audio" : "Hiện trình phát audio"}
className={`flex items-center gap-1 text-xs font-semibold px-3 py-1.5 rounded-lg transition ${
showAudio
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-100 hover:bg-gray-200 text-gray-700"
} disabled:opacity-60`}
>
<i className="fa-solid fa-music" style={{ fontSize: 13 }} />
{audioTracks.length > 0 && (
<span className={`text-[10px] font-bold px-1 py-0 rounded-full ${
showAudio ? "bg-white text-blue-600" : "bg-blue-600 text-white"
}`}>{audioTracks.length}</span>
)}
</button>
)}
{/* Export PDF with annotations baked in */}
<button
onClick={handleExportPDF}
@@ -1735,6 +1747,15 @@ export default function WorkbookViewer({ pdfId }: Props) {
</div>
)}
{showAudio && typeof document !== "undefined" && (
<FloatingAudioPlayer
pdfId={pdfId}
tracks={audioTracks}
onTracksChange={setAudioTracks}
userRole={userRole}
/>
)}
{colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
<div
ref={colorPickerRef}
+32
View File
@@ -101,4 +101,36 @@ export const api = {
}
return res.json();
},
// Audio
listAudio: (pdfId: number) =>
request<import("@/types").AudioTrack[]>(`/api/pdfs/${pdfId}/audio`),
uploadAudio: async (
pdfId: number,
files: File[],
meta: { trackName?: string; pageNumber?: number; sortOrder?: number }[] = [],
): Promise<import("@/types").AudioUploadResult[]> => {
const form = new FormData();
files.forEach((f) => form.append("files", f));
meta.forEach((m) => form.append("track_names", m.trackName ?? ""));
meta.forEach((m) => form.append("page_numbers", String(m.pageNumber ?? 0)));
meta.forEach((m) => form.append("sort_orders", String(m.sortOrder ?? 0)));
const res = await fetch(`/api/pdfs/${pdfId}/audio`, {
method: "POST",
credentials: "include",
body: form,
});
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(detail?.detail ?? "Upload failed");
}
return res.json();
},
deleteAudio: (pdfId: number, audioId: number) =>
request<void>(`/api/pdfs/${pdfId}/audio/${audioId}`, { method: "DELETE" }),
audioFileUrl: (pdfId: number, audioId: number) =>
`/api/pdfs/${pdfId}/audio/${audioId}/file`,
};
+16
View File
@@ -34,3 +34,19 @@ export interface AnnotationData {
canvas_data: Record<string, any>;
updated_at: string;
}
export interface AudioTrack {
id: number;
pdf_id: number;
track_name: string;
page_number: number | null;
sort_order: number;
created_at: string;
}
export interface AudioUploadResult {
filename: string;
success: boolean;
audio: AudioTrack | null;
error: string | null;
}
+3 -3
View File
@@ -24,10 +24,10 @@ POSTGRES_DB="${POSTGRES_DB:-lms_db}"
POSTGRES_USER="${POSTGRES_USER:-lms_user}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-lms_test_password_123}"
JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set in .env}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
BACKEND_PORT="${BACKEND_PORT:-8000}"
FRONTEND_PORT="${FRONTEND_PORT:-3011}"
BACKEND_PORT="${BACKEND_PORT:-8001}"
DATABASE_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}"
DATABASE_URL="postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5433/${POSTGRES_DB}"
UPLOAD_DIR="./backend/uploads"
# ── Colors ────────────────────────────────────────────────────────────────────