mirror of
https://git.victorphan.net/basketballcantho/ten-project.git
synced 2026-08-05 10: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 ..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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
try {
|
||||||
await singleRenderTask.promise;
|
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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -150,3 +150,23 @@ Lệnh Kết quả tag
|
|||||||
./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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user