mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 09:03:11 +07:00
hoàn thành tính năng mp3 player
This commit is contained in:
@@ -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
@@ -4,7 +4,7 @@ import os
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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 .database import SessionLocal
|
||||||
from .models import User, UserRole, UserStatus
|
from .models import User, UserRole, UserStatus
|
||||||
from .security import hash_password
|
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(admin.router, prefix="/api")
|
||||||
app.include_router(backup.router, prefix="/api")
|
app.include_router(backup.router, prefix="/api")
|
||||||
app.include_router(pdfs.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(annotations.router, prefix="/api")
|
||||||
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
|
app.include_router(ws.router) # WebSocket — no /api prefix (ws:// path)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy import (
|
|||||||
ForeignKey,
|
ForeignKey,
|
||||||
DateTime,
|
DateTime,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
Boolean,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -57,6 +58,7 @@ class PDF(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
owner = relationship("User", back_populates="pdfs")
|
owner = relationship("User", back_populates="pdfs")
|
||||||
annotations = relationship("Annotation", back_populates="pdf", cascade="all, delete-orphan")
|
annotations = relationship("Annotation", back_populates="pdf", cascade="all, delete-orphan")
|
||||||
|
audio_tracks = relationship("PDFAudio", back_populates="pdf", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
class Annotation(Base):
|
class Annotation(Base):
|
||||||
@@ -81,3 +83,18 @@ class Annotation(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
pdf = relationship("PDF", back_populates="annotations")
|
pdf = relationship("PDF", back_populates="annotations")
|
||||||
owner = relationship("User", 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")
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
)
|
||||||
@@ -98,6 +98,26 @@ class AnnotationIn(BaseModel):
|
|||||||
canvas_data: dict
|
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):
|
class AnnotationOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
pdf_id: int
|
pdf_id: int
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lms_dev_password}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lms_dev_password}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms_user} -d ${POSTGRES_DB:-lms_db}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lms_user} -d ${POSTGRES_DB:-lms_db}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<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>
|
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
|
||||||
</html>
|
</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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { createPortal } from "react-dom";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useCollaboration, type RemoteEvent } from "@/hooks/useCollaboration";
|
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)
|
// Preset color palette (30 hues, 3 brightness levels × 10 hue families)
|
||||||
@@ -63,13 +65,13 @@ function ToolBtn({
|
|||||||
current,
|
current,
|
||||||
onClick,
|
onClick,
|
||||||
title,
|
title,
|
||||||
children,
|
iconClass,
|
||||||
}: {
|
}: {
|
||||||
id: Tool;
|
id: Tool;
|
||||||
current: Tool;
|
current: Tool;
|
||||||
onClick: (t: Tool) => void;
|
onClick: (t: Tool) => void;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
iconClass: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -81,9 +83,7 @@ function ToolBtn({
|
|||||||
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800"
|
: "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">
|
<i className={`${iconClass} w-4 text-center`} style={{ fontSize: 15 }} />
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -130,6 +130,8 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
const [saveNotice, setSaveNotice] = useState(false);
|
const [saveNotice, setSaveNotice] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
|
||||||
|
const [showAudio, setShowAudio] = useState(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);
|
||||||
@@ -246,6 +248,11 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch audio tracks for this PDF
|
||||||
|
useEffect(() => {
|
||||||
|
api.listAudio(pdfId).then(setAudioTracks).catch(() => {});
|
||||||
|
}, [pdfId]);
|
||||||
|
|
||||||
// Close color picker when clicking outside
|
// Close color picker when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!colorPickerOpen) return;
|
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="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">
|
<ToolBtn id="select" current={tool} onClick={setTool} title="Select / Move" iconClass="fa-solid fa-arrow-pointer" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen" iconClass="fa-solid fa-pen" />
|
||||||
</ToolBtn>
|
<ToolBtn id="highlighter" current={tool} onClick={setTool} title="Highlighter" iconClass="fa-solid fa-highlighter" />
|
||||||
<ToolBtn id="pen" current={tool} onClick={setTool} title="Pen">
|
<ToolBtn id="text" current={tool} onClick={setTool} title="Text" iconClass="fa-solid fa-font" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
<ToolBtn id="eraser" current={tool} onClick={setTool} title="Eraser" iconClass="fa-solid fa-eraser" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color picker button */}
|
{/* Color picker button */}
|
||||||
@@ -1609,6 +1602,25 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
{saveNotice && (
|
{saveNotice && (
|
||||||
<span className="text-xs text-green-600 font-medium animate-pulse">✓ Saved</span>
|
<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 */}
|
{/* Export PDF with annotations baked in */}
|
||||||
<button
|
<button
|
||||||
onClick={handleExportPDF}
|
onClick={handleExportPDF}
|
||||||
@@ -1735,6 +1747,15 @@ export default function WorkbookViewer({ pdfId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showAudio && typeof document !== "undefined" && (
|
||||||
|
<FloatingAudioPlayer
|
||||||
|
pdfId={pdfId}
|
||||||
|
tracks={audioTracks}
|
||||||
|
onTracksChange={setAudioTracks}
|
||||||
|
userRole={userRole}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
|
{colorPickerOpen && colorPickerPos && typeof document !== "undefined" && createPortal(
|
||||||
<div
|
<div
|
||||||
ref={colorPickerRef}
|
ref={colorPickerRef}
|
||||||
|
|||||||
@@ -101,4 +101,36 @@ export const api = {
|
|||||||
}
|
}
|
||||||
return res.json();
|
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`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,3 +34,19 @@ export interface AnnotationData {
|
|||||||
canvas_data: Record<string, any>;
|
canvas_data: Record<string, any>;
|
||||||
updated_at: string;
|
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
@@ -24,10 +24,10 @@ POSTGRES_DB="${POSTGRES_DB:-lms_db}"
|
|||||||
POSTGRES_USER="${POSTGRES_USER:-lms_user}"
|
POSTGRES_USER="${POSTGRES_USER:-lms_user}"
|
||||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-lms_test_password_123}"
|
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-lms_test_password_123}"
|
||||||
JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set in .env}"
|
JWT_SECRET_KEY="${JWT_SECRET_KEY:?JWT_SECRET_KEY is not set in .env}"
|
||||||
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
|
FRONTEND_PORT="${FRONTEND_PORT:-3011}"
|
||||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
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"
|
UPLOAD_DIR="./backend/uploads"
|
||||||
|
|
||||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user