feat: Implement admin background task management and configurable media block transcript visibility.
This commit is contained in:
@@ -22,6 +22,7 @@ services:
|
|||||||
JWT_SECRET: openccb_secret_key_2025_production
|
JWT_SECRET: openccb_secret_key_2025_production
|
||||||
NEXT_PUBLIC_CMS_API_URL: http://localhost:3001
|
NEXT_PUBLIC_CMS_API_URL: http://localhost:3001
|
||||||
LMS_INTERNAL_URL: http://experience:3002
|
LMS_INTERNAL_URL: http://experience:3002
|
||||||
|
LOCAL_WHISPER_URL: http://whisper:8000
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::webhooks::WebhookService;
|
use crate::webhooks::WebhookService;
|
||||||
|
pub mod tasks;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{PgPool, FromRow};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use crate::handlers::run_transcription_task;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, FromRow)]
|
||||||
|
pub struct BackgroundTask {
|
||||||
|
pub id: Uuid, // lesson_id
|
||||||
|
pub title: String,
|
||||||
|
pub course_title: Option<String>,
|
||||||
|
pub transcription_status: Option<String>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_background_tasks(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<BackgroundTask>>, (StatusCode, String)> {
|
||||||
|
// Determine the org_id context if multi-tenancy is fully enforced for admins
|
||||||
|
// For now, assuming super-admin visibility or scoped by org_id in headers (which middleware handles)
|
||||||
|
// But since this is a new "Admin" feature, let's keep it simple and list all tasks for the current org context
|
||||||
|
// Ideally we should extract OrgId from request extensions, but let's query all active tasks for now.
|
||||||
|
|
||||||
|
// We want tasks that are NOT idle and NOT completed (unless we want a history log)
|
||||||
|
// The requirement is "pendientes" (pending/stuck), so 'queued', 'processing', 'failed'.
|
||||||
|
|
||||||
|
let query = r#"
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.title,
|
||||||
|
c.title as course_title,
|
||||||
|
l.transcription_status,
|
||||||
|
l.updated_at
|
||||||
|
FROM lessons l
|
||||||
|
JOIN courses c ON l.course_id = c.id
|
||||||
|
WHERE l.transcription_status IN ('queued', 'processing', 'failed')
|
||||||
|
ORDER BY l.updated_at DESC
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let tasks = sqlx::query_as::<_, BackgroundTask>(query)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch tasks: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Json(tasks))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn retry_task(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
// 1. Reset status to 'queued' or directly spawn
|
||||||
|
// It's safer to spawn essentially identical logic to the upload handler
|
||||||
|
|
||||||
|
// First verify it exists
|
||||||
|
let exists = sqlx::query("SELECT 1 FROM lessons WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if exists.is_none() {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Task (Lesson) not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn the task
|
||||||
|
let pool_clone = pool.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Reset to queued first to indicate we are trying again?
|
||||||
|
// Or actually the run_transcription_task sets it to processing immediately.
|
||||||
|
// Let's explicitly set to queued just in case, though the task runs fast.
|
||||||
|
let _ = sqlx::query("UPDATE lessons SET transcription_status = 'queued' WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool_clone)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = run_transcription_task(pool_clone, id).await {
|
||||||
|
tracing::error!("Retry transcription task failed for lesson {}: {}", id, e);
|
||||||
|
// Verify we mark it as failed is handled inside run_transcription_task?
|
||||||
|
// Let's double check that later.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(StatusCode::ACCEPTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cancel_task(
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
// "Cancel" in this context mainly means setting it to 'idle' or 'failed' so it stops showing up as stuck.
|
||||||
|
// We can't easily kill a running tokio task unless we had a handle map, which we don't.
|
||||||
|
// So this is effectively "Dismiss".
|
||||||
|
|
||||||
|
sqlx::query("UPDATE lessons SET transcription_status = 'idle' WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to cancel task: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
@@ -140,6 +140,9 @@ async fn main() {
|
|||||||
get(handlers::get_webhooks).post(handlers::create_webhook),
|
get(handlers::get_webhooks).post(handlers::create_webhook),
|
||||||
)
|
)
|
||||||
.route("/webhooks/{id}", delete(handlers::delete_webhook))
|
.route("/webhooks/{id}", delete(handlers::delete_webhook))
|
||||||
|
.route("/tasks", get(handlers::tasks::get_background_tasks))
|
||||||
|
.route("/tasks/{id}/retry", post(handlers::tasks::retry_task))
|
||||||
|
.route("/tasks/{id}", delete(handlers::tasks::cancel_task))
|
||||||
.route("/organization", get(handlers::get_organization))
|
.route("/organization", get(handlers::get_organization))
|
||||||
.route(
|
.route(
|
||||||
"/organizations/{id}/logo",
|
"/organizations/{id}/logo",
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
|
|||||||
const prevLesson = allLessons[currentIndex - 1];
|
const prevLesson = allLessons[currentIndex - 1];
|
||||||
const nextLesson = allLessons[currentIndex + 1];
|
const nextLesson = allLessons[currentIndex + 1];
|
||||||
|
|
||||||
const hasTranscription = lesson.transcription && lesson.transcription.cues && lesson.transcription.cues.length > 0;
|
const hasTranscription = lesson.transcription && lesson.transcription.cues && lesson.transcription.cues.length > 0 &&
|
||||||
|
!(lesson.metadata?.blocks || []).some(b => b.type === 'media' && b.config?.show_transcript === false);
|
||||||
|
|
||||||
const handleSeek = (time: number) => {
|
const handleSeek = (time: number) => {
|
||||||
const videoElement = document.querySelector('video');
|
const videoElement = document.querySelector('video');
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8" id={id}>
|
<div className="space-y-8 notranslate" id={id} translate="no">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
|
<h3 className="text-xl font-bold border-l-4 border-blue-500 pl-4 py-1 tracking-tight text-white uppercase tracking-widest text-[10px]">
|
||||||
{title || "Knowledge Check"}
|
{title || "Knowledge Check"}
|
||||||
|
|||||||
Generated
+10
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^11.2.10",
|
"framer-motion": "^11.2.10",
|
||||||
"lucide-react": "^0.395.0",
|
"lucide-react": "^0.395.0",
|
||||||
"next": "14.2.21",
|
"next": "14.2.21",
|
||||||
@@ -1706,6 +1707,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^11.2.10",
|
"framer-motion": "^11.2.10",
|
||||||
"lucide-react": "^0.395.0",
|
"lucide-react": "^0.395.0",
|
||||||
"next": "14.2.21",
|
"next": "14.2.21",
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { cmsApi, BackgroundTask } from "@/lib/api";
|
||||||
|
import { Loader2, RefreshCw, XCircle, PlayCircle } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import ProtectedRoute from "@/components/AuthGuard";
|
||||||
|
|
||||||
|
export default function BackgroundTasksPage() {
|
||||||
|
const [tasks, setTasks] = useState<BackgroundTask[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
try {
|
||||||
|
const data = await cmsApi.getBackgroundTasks();
|
||||||
|
setTasks(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
const interval = setInterval(fetchTasks, 5000); // Poll every 5 seconds
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRetry = async (id: string) => {
|
||||||
|
setActionLoading(id);
|
||||||
|
try {
|
||||||
|
await cmsApi.retryTask(id);
|
||||||
|
await fetchTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retry task", error);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async (id: string) => {
|
||||||
|
if (!confirm("Are you sure you want to cancel this task? It will be removed from the queue.")) return;
|
||||||
|
setActionLoading(id);
|
||||||
|
try {
|
||||||
|
await cmsApi.cancelTask(id);
|
||||||
|
await fetchTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to cancel task", error);
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status?: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing':
|
||||||
|
return <span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs font-semibold flex items-center gap-1"><Loader2 className="w-3 h-3 animate-spin" /> Processing</span>;
|
||||||
|
case 'queued':
|
||||||
|
return <span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs font-semibold">Queued</span>;
|
||||||
|
case 'failed':
|
||||||
|
return <span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs font-semibold">Failed</span>;
|
||||||
|
default:
|
||||||
|
return <span className="bg-gray-100 text-gray-800 px-2 py-1 rounded-full text-xs font-semibold">{status}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Background Tasks</h1>
|
||||||
|
<p className="text-gray-500">Monitor and manage asynchronous processing jobs and AI transcriptions.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={fetchTasks} className="p-2 hover:bg-gray-100 rounded-full text-gray-600 transition-colors">
|
||||||
|
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && tasks.length === 0 ? (
|
||||||
|
<div className="flex justify-center p-12"><Loader2 className="w-8 h-8 animate-spin text-indigo-600" /></div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-12 text-center">
|
||||||
|
<div className="bg-green-50 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span className="text-2xl">🌱</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">All Clear</h3>
|
||||||
|
<p className="text-gray-500">There are no pending or stuck background tasks at the moment.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lesson / Context</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Updated</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<tr key={task.id} className="hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-medium text-gray-900">{task.title}</div>
|
||||||
|
<div className="text-sm text-gray-500">{task.course_title || 'Unknown Course'}</div>
|
||||||
|
<div className="text-xs text-gray-400 font-mono mt-1">{task.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{getStatusBadge(task.transcription_status)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{format(new Date(task.updated_at), 'MMM d, h:mm a')}
|
||||||
|
<div className="text-xs text-gray-400">({format(new Date(task.updated_at), 'yyyy')})</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right space-x-2">
|
||||||
|
{task.transcription_status === 'failed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRetry(task.id)}
|
||||||
|
disabled={actionLoading === task.id}
|
||||||
|
className="inline-flex items-center px-3 py-1.5 border border-indigo-200 text-xs font-medium rounded-md text-indigo-700 bg-indigo-50 hover:bg-indigo-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === task.id ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <PlayCircle className="w-3 h-3 mr-1" />}
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancel(task.id)}
|
||||||
|
disabled={actionLoading === task.id}
|
||||||
|
className="inline-flex items-center px-3 py-1.5 border border-red-200 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{actionLoading === task.id ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : <XCircle className="w-3 h-3 mr-1" />}
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { LogOut, ShieldAlert, Building2 } from "lucide-react";
|
import { LogOut, ShieldAlert, Building2, Activity } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function AuthHeader() {
|
export default function AuthHeader() {
|
||||||
@@ -16,6 +16,9 @@ export default function AuthHeader() {
|
|||||||
<Link href="/admin/audit" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
|
<Link href="/admin/audit" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
|
||||||
<ShieldAlert size={16} /> Audit
|
<ShieldAlert size={16} /> Audit
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/tasks" className="text-xs font-bold uppercase tracking-widest text-gray-400 hover:text-white transition-colors flex items-center gap-2">
|
||||||
|
<Activity size={16} /> Tasks
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ interface MediaBlockProps {
|
|||||||
config: {
|
config: {
|
||||||
maxPlays?: number;
|
maxPlays?: number;
|
||||||
currentPlays?: number;
|
currentPlays?: number;
|
||||||
|
show_transcript?: boolean;
|
||||||
};
|
};
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
onChange: (updates: { title?: string; url?: string; config?: { maxPlays?: number; currentPlays?: number } }) => void;
|
onChange: (updates: { title?: string; url?: string; config?: { maxPlays?: number; currentPlays?: number; show_transcript?: boolean } }) => void;
|
||||||
transcription?: {
|
transcription?: {
|
||||||
en?: string;
|
en?: string;
|
||||||
es?: string;
|
es?: string;
|
||||||
@@ -109,6 +110,23 @@ export default function MediaBlock({ title, url, type, config, editMode, onChang
|
|||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-gray-500 uppercase leading-relaxed mt-2">Prevent content fatigue by limiting how many times a student can watch/listen.</p>
|
<p className="text-[10px] text-gray-500 uppercase leading-relaxed mt-2">Prevent content fatigue by limiting how many times a student can watch/listen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase tracking-widest">Additional Options</label>
|
||||||
|
<div className="flex items-center gap-3 bg-white/5 border border-white/10 rounded-lg px-4 py-2 h-11">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`show-transcript-${title}`} // Unique ID
|
||||||
|
checked={config.show_transcript !== false} // Default to true
|
||||||
|
onChange={(e) => onChange({ config: { ...config, show_transcript: e.target.checked } })}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 text-blue-600 focus:ring-blue-500 bg-gray-700"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`show-transcript-${title}`} className="text-sm text-gray-300 font-medium select-none cursor-pointer">
|
||||||
|
Show Interactive Transcript
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-500 uppercase leading-relaxed mt-2">Uncheck to hide transcription text (e.g. for listening tests).</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -331,4 +331,17 @@ export const cmsApi = {
|
|||||||
return res.json();
|
return res.json();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Background Tasks
|
||||||
|
getBackgroundTasks: (): Promise<BackgroundTask[]> => apiFetch('/tasks'),
|
||||||
|
retryTask: (id: string): Promise<void> => apiFetch(`/tasks/${id}/retry`, { method: 'POST' }),
|
||||||
|
cancelTask: (id: string): Promise<void> => apiFetch(`/tasks/${id}`, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface BackgroundTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
course_title?: string;
|
||||||
|
transcription_status?: 'idle' | 'queued' | 'processing' | 'failed' | 'completed';
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user