feat: agregar historial de importación de ZIP y metadatos a los activos
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE assets
|
||||||
|
ADD COLUMN IF NOT EXISTS zip_batch_id UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS source_zip_name TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_zip_batch_id
|
||||||
|
ON assets (organization_id, zip_batch_id)
|
||||||
|
WHERE zip_batch_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_source_zip_name
|
||||||
|
ON assets (organization_id, source_zip_name)
|
||||||
|
WHERE source_zip_name IS NOT NULL;
|
||||||
@@ -72,6 +72,17 @@ pub struct AssetZipImportResponse {
|
|||||||
pub rag_background_items: usize,
|
pub rag_background_items: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct AssetImportHistoryItem {
|
||||||
|
pub zip_batch_id: Uuid,
|
||||||
|
pub source_zip_name: String,
|
||||||
|
pub english_level: Option<String>,
|
||||||
|
pub sam_plan_id: Option<i32>,
|
||||||
|
pub sam_course_id: Option<i32>,
|
||||||
|
pub asset_count: i64,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AssetFilters {
|
pub struct AssetFilters {
|
||||||
pub mimetype: Option<String>,
|
pub mimetype: Option<String>,
|
||||||
@@ -608,6 +619,37 @@ pub async fn list_assets(
|
|||||||
Ok(Json(assets))
|
Ok(Json(assets))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_asset_import_history(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<AssetImportHistoryItem>>, (StatusCode, String)> {
|
||||||
|
let items = sqlx::query_as::<_, AssetImportHistoryItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
zip_batch_id,
|
||||||
|
source_zip_name,
|
||||||
|
english_level,
|
||||||
|
sam_plan_id,
|
||||||
|
sam_course_id,
|
||||||
|
COUNT(*)::bigint AS asset_count,
|
||||||
|
MAX(created_at) AS created_at
|
||||||
|
FROM assets
|
||||||
|
WHERE organization_id = $1
|
||||||
|
AND zip_batch_id IS NOT NULL
|
||||||
|
AND source_zip_name IS NOT NULL
|
||||||
|
GROUP BY zip_batch_id, source_zip_name, english_level, sam_plan_id, sam_course_id
|
||||||
|
ORDER BY MAX(created_at) DESC
|
||||||
|
LIMIT 100
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|
||||||
/// DELETE /api/assets/:id - Eliminar un activo y su archivo físico
|
/// DELETE /api/assets/:id - Eliminar un activo y su archivo físico
|
||||||
pub async fn delete_asset(
|
pub async fn delete_asset(
|
||||||
Org(org_ctx): Org,
|
Org(org_ctx): Org,
|
||||||
@@ -879,6 +921,8 @@ async fn process_zip_entry_without_rag(
|
|||||||
org_id: Uuid,
|
org_id: Uuid,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
|
zip_batch_id: Uuid,
|
||||||
|
source_zip_name: String,
|
||||||
course_id: Option<Uuid>,
|
course_id: Option<Uuid>,
|
||||||
english_level: Option<String>,
|
english_level: Option<String>,
|
||||||
sam_plan_id: Option<i32>,
|
sam_plan_id: Option<i32>,
|
||||||
@@ -1054,13 +1098,15 @@ async fn process_zip_entry_without_rag(
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes)
|
INSERT INTO assets (id, organization_id, uploaded_by, zip_batch_id, source_zip_name, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(asset_id)
|
.bind(asset_id)
|
||||||
.bind(org_id)
|
.bind(org_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(zip_batch_id)
|
||||||
|
.bind(source_zip_name)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.bind(&english_level)
|
.bind(&english_level)
|
||||||
.bind(sam_plan_id)
|
.bind(sam_plan_id)
|
||||||
@@ -1103,6 +1149,7 @@ pub async fn import_assets_zip(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut zip_temp_path: Option<String> = None;
|
let mut zip_temp_path: Option<String> = None;
|
||||||
|
let mut zip_original_name: Option<String> = None;
|
||||||
let mut course_id: Option<Uuid> = None;
|
let mut course_id: Option<Uuid> = None;
|
||||||
let mut english_level: Option<String> = None;
|
let mut english_level: Option<String> = None;
|
||||||
let mut sam_plan_id: Option<i32> = None;
|
let mut sam_plan_id: Option<i32> = None;
|
||||||
@@ -1121,6 +1168,15 @@ pub async fn import_assets_zip(
|
|||||||
let name = field.name().unwrap_or_default().to_string();
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
|
|
||||||
if name == "file" {
|
if name == "file" {
|
||||||
|
if let Some(file_name) = field.file_name() {
|
||||||
|
zip_original_name = Some(
|
||||||
|
StdPath::new(file_name)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or(file_name)
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
let temp_name = format!("uploads/tmp/import-{}.zip", Uuid::new_v4());
|
let temp_name = format!("uploads/tmp/import-{}.zip", Uuid::new_v4());
|
||||||
tokio::fs::create_dir_all("uploads/tmp")
|
tokio::fs::create_dir_all("uploads/tmp")
|
||||||
.await
|
.await
|
||||||
@@ -1221,6 +1277,8 @@ pub async fn import_assets_zip(
|
|||||||
return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "No ZIP file uploaded".to_string()));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let zip_batch_id = Uuid::new_v4();
|
||||||
|
let source_zip_name = zip_original_name.unwrap_or_else(|| "import.zip".to_string());
|
||||||
|
|
||||||
let zip_file = std::fs::File::open(&zip_path)
|
let zip_file = std::fs::File::open(&zip_path)
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to open temp zip file: {}", e)))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to open temp zip file: {}", e)))?;
|
||||||
@@ -1399,6 +1457,7 @@ pub async fn import_assets_zip(
|
|||||||
let english_level_cl = english_level.clone();
|
let english_level_cl = english_level.clone();
|
||||||
let s3_settings_cl = s3_settings.clone();
|
let s3_settings_cl = s3_settings.clone();
|
||||||
let s3_client_cl = s3_client.clone();
|
let s3_client_cl = s3_client.clone();
|
||||||
|
let source_zip_name_cl = source_zip_name.clone();
|
||||||
|
|
||||||
join_set.spawn(async move {
|
join_set.spawn(async move {
|
||||||
process_zip_entry_without_rag(
|
process_zip_entry_without_rag(
|
||||||
@@ -1406,6 +1465,8 @@ pub async fn import_assets_zip(
|
|||||||
org_id,
|
org_id,
|
||||||
user_id,
|
user_id,
|
||||||
pool_cl,
|
pool_cl,
|
||||||
|
zip_batch_id,
|
||||||
|
source_zip_name_cl,
|
||||||
course_id,
|
course_id,
|
||||||
english_level_cl,
|
english_level_cl,
|
||||||
sam_plan_id,
|
sam_plan_id,
|
||||||
@@ -1598,13 +1659,15 @@ pub async fn import_assets_zip(
|
|||||||
|
|
||||||
let insert_result = sqlx::query(
|
let insert_result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO assets (id, organization_id, uploaded_by, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes)
|
INSERT INTO assets (id, organization_id, uploaded_by, zip_batch_id, source_zip_name, course_id, english_level, sam_plan_id, sam_course_id, unit_number, filename, storage_path, mimetype, size_bytes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(asset_id)
|
.bind(asset_id)
|
||||||
.bind(org_ctx.id)
|
.bind(org_ctx.id)
|
||||||
.bind(claims.sub)
|
.bind(claims.sub)
|
||||||
|
.bind(zip_batch_id)
|
||||||
|
.bind(&source_zip_name)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
.bind(&english_level)
|
.bind(&english_level)
|
||||||
.bind(sam_plan_id)
|
.bind(sam_plan_id)
|
||||||
@@ -1641,6 +1704,8 @@ pub async fn import_assets_zip(
|
|||||||
sam_plan_id,
|
sam_plan_id,
|
||||||
sam_course_id: effective_sam_course_id,
|
sam_course_id: effective_sam_course_id,
|
||||||
unit_number,
|
unit_number,
|
||||||
|
zip_batch_id: Some(zip_batch_id),
|
||||||
|
source_zip_name: Some(source_zip_name.clone()),
|
||||||
filename: stored_filename.clone(),
|
filename: stored_filename.clone(),
|
||||||
storage_path: db_storage_path.clone(),
|
storage_path: db_storage_path.clone(),
|
||||||
mimetype: mimetype.clone(),
|
mimetype: mimetype.clone(),
|
||||||
|
|||||||
@@ -276,6 +276,7 @@ async fn main() {
|
|||||||
.route("/audit-logs", get(handlers::get_audit_logs))
|
.route("/audit-logs", get(handlers::get_audit_logs))
|
||||||
.route("/api/ai/review-text", post(handlers::review_text))
|
.route("/api/ai/review-text", post(handlers::review_text))
|
||||||
.route("/api/assets", get(handlers_assets::list_assets))
|
.route("/api/assets", get(handlers_assets::list_assets))
|
||||||
|
.route("/api/assets/import-history", get(handlers_assets::list_asset_import_history))
|
||||||
.route("/api/assets/upload", post(handlers_assets::upload_asset))
|
.route("/api/assets/upload", post(handlers_assets::upload_asset))
|
||||||
.route("/api/assets/import-zip", post(handlers_assets::import_assets_zip))
|
.route("/api/assets/import-zip", post(handlers_assets::import_assets_zip))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -349,6 +349,8 @@ pub struct Asset {
|
|||||||
pub sam_plan_id: Option<i32>,
|
pub sam_plan_id: Option<i32>,
|
||||||
pub sam_course_id: Option<i32>,
|
pub sam_course_id: Option<i32>,
|
||||||
pub unit_number: Option<i32>,
|
pub unit_number: Option<i32>,
|
||||||
|
pub zip_batch_id: Option<Uuid>,
|
||||||
|
pub source_zip_name: Option<String>,
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub storage_path: String,
|
pub storage_path: String,
|
||||||
pub mimetype: String,
|
pub mimetype: String,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { cmsApi, questionBankApi, MySqlPlan, MySqlCourse } from '@/lib/api';
|
import { cmsApi, questionBankApi, MySqlPlan, MySqlCourse, AssetImportHistoryItem } from '@/lib/api';
|
||||||
import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle, Scissors } from 'lucide-react';
|
import { Upload, Database, FileArchive, CheckCircle2, AlertTriangle, Scissors } from 'lucide-react';
|
||||||
|
|
||||||
export default function AdminSharedMaterialsPage() {
|
export default function AdminSharedMaterialsPage() {
|
||||||
@@ -31,6 +31,7 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
rag_background_started?: boolean;
|
rag_background_started?: boolean;
|
||||||
rag_background_items?: number;
|
rag_background_items?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [importHistory, setImportHistory] = useState<AssetImportHistoryItem[]>([]);
|
||||||
|
|
||||||
const canUpload = useMemo(() => Boolean(zipFile) && !loading, [zipFile, loading]);
|
const canUpload = useMemo(() => Boolean(zipFile) && !loading, [zipFile, loading]);
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
questionBankApi.getMySQLPlans().then(setPlans).catch(() => setPlans([]));
|
questionBankApi.getMySQLPlans().then(setPlans).catch(() => setPlans([]));
|
||||||
|
cmsApi.getAssetImportHistory().then(setImportHistory).catch(() => setImportHistory([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -165,6 +167,7 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
useDevProcessing,
|
useDevProcessing,
|
||||||
);
|
);
|
||||||
setResult(response);
|
setResult(response);
|
||||||
|
cmsApi.getAssetImportHistory().then(setImportHistory).catch(() => setImportHistory([]));
|
||||||
setPhase('done');
|
setPhase('done');
|
||||||
alert('Importacion ZIP finalizada.');
|
alert('Importacion ZIP finalizada.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -415,6 +418,48 @@ export default function AdminSharedMaterialsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/[0.02] p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-900 dark:text-white">ZIPs ya importados</h3>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-gray-400 mt-1">
|
||||||
|
Revisa nombre del ZIP y nivel antes de repetir una carga.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{importHistory.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-gray-400">Aun no hay importaciones ZIP registradas.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-80 overflow-y-auto pr-1">
|
||||||
|
{importHistory.map((item) => (
|
||||||
|
<div key={item.zip_batch_id} className="rounded-xl border border-slate-200 dark:border-white/10 p-4 bg-slate-50/70 dark:bg-white/[0.03]">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-white break-all">{item.source_zip_name}</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-gray-400 mt-1">
|
||||||
|
{new Date(item.created_at).toLocaleString()} · {item.asset_count} assets
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-end">
|
||||||
|
<span className="rounded-full border border-slate-300 dark:border-white/10 px-3 py-1 text-[11px] font-semibold text-slate-700 dark:text-gray-300">
|
||||||
|
Nivel: {item.english_level || 'general'}
|
||||||
|
</span>
|
||||||
|
{item.sam_plan_id && (
|
||||||
|
<span className="rounded-full border border-slate-300 dark:border-white/10 px-3 py-1 text-[11px] font-semibold text-slate-700 dark:text-gray-300">
|
||||||
|
Plan SAM: {item.sam_plan_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.sam_course_id && (
|
||||||
|
<span className="rounded-full border border-slate-300 dark:border-white/10 px-3 py-1 text-[11px] font-semibold text-slate-700 dark:text-gray-300">
|
||||||
|
Curso SAM: {item.sam_course_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/[0.02] p-6 space-y-4">
|
<div className="rounded-2xl border border-slate-200 dark:border-white/10 bg-white dark:bg-white/[0.02] p-6 space-y-4">
|
||||||
<h3 className="text-lg font-bold text-slate-900 dark:text-white">Resultado de la Importacion</h3>
|
<h3 className="text-lg font-bold text-slate-900 dark:text-white">Resultado de la Importacion</h3>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from "react";
|
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Search, Image as ImageIcon, FileText, Film, File as FileIcon,
|
Search, Image as ImageIcon, FileText, Film, File as FileIcon,
|
||||||
Loader2, Upload, Trash2, ExternalLink, Filter, Plus
|
Loader2, Upload, Trash2, ExternalLink, Filter, Plus
|
||||||
@@ -11,38 +11,70 @@ import { useTranslation } from "@/context/I18nContext";
|
|||||||
import PageLayout from "@/components/PageLayout";
|
import PageLayout from "@/components/PageLayout";
|
||||||
|
|
||||||
export default function AssetLibraryPage() {
|
export default function AssetLibraryPage() {
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [filterType, setFilterType] = useState<string>("all");
|
const [filterType, setFilterType] = useState<string>("all");
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const loadAssets = useCallback(async () => {
|
const loadAssets = useCallback(async (nextPage = 1, append = false) => {
|
||||||
|
if (append) {
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
} else {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const filters: AssetFilters = {};
|
const filters: AssetFilters = { page: nextPage, limit: PAGE_SIZE };
|
||||||
if (searchTerm) filters.search = searchTerm;
|
if (searchTerm) filters.search = searchTerm;
|
||||||
if (filterType !== "all") filters.mimetype = filterType;
|
if (filterType !== "all") filters.mimetype = filterType;
|
||||||
|
|
||||||
const data = await cmsApi.getAssets(filters);
|
const data = await cmsApi.getAssets(filters);
|
||||||
setAssets(data);
|
setAssets((prev) => append ? [...prev, ...data] : data);
|
||||||
|
setPage(nextPage);
|
||||||
|
setHasMore(data.length === PAGE_SIZE);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load assets:", error);
|
console.error("Failed to load assets:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (append) {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
} else {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [searchTerm, filterType]);
|
}, [searchTerm, filterType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
loadAssets();
|
setAssets([]);
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(true);
|
||||||
|
loadAssets(1, false);
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [loadAssets]);
|
}, [loadAssets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sentinelRef.current || !hasMore || isLoading || isLoadingMore) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
const first = entries[0];
|
||||||
|
if (first?.isIntersecting) {
|
||||||
|
loadAssets(page + 1, true);
|
||||||
|
}
|
||||||
|
}, { rootMargin: '300px' });
|
||||||
|
|
||||||
|
observer.observe(sentinelRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMore, isLoading, isLoadingMore, loadAssets, page]);
|
||||||
|
|
||||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
@@ -56,7 +88,7 @@ export default function AssetLibraryPage() {
|
|||||||
setUploadProgress(pct);
|
setUploadProgress(pct);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
loadAssets();
|
loadAssets(1, false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Upload failed:", error);
|
console.error("Upload failed:", error);
|
||||||
alert("Failed to upload assets.");
|
alert("Failed to upload assets.");
|
||||||
@@ -237,6 +269,20 @@ export default function AssetLibraryPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isLoading && assets.length > 0 && (
|
||||||
|
<div ref={sentinelRef} className="flex justify-center py-8">
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-500 dark:text-gray-400">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Cargando mas assets...
|
||||||
|
</div>
|
||||||
|
) : !hasMore ? (
|
||||||
|
<div className="text-xs font-bold uppercase tracking-[0.2em] text-slate-400 dark:text-gray-500">
|
||||||
|
Fin de la biblioteca
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -725,6 +725,8 @@ export interface Asset {
|
|||||||
uploaded_by: string | null;
|
uploaded_by: string | null;
|
||||||
course_id: string | null;
|
course_id: string | null;
|
||||||
english_level?: string | null;
|
english_level?: string | null;
|
||||||
|
zip_batch_id?: string | null;
|
||||||
|
source_zip_name?: string | null;
|
||||||
filename: string;
|
filename: string;
|
||||||
storage_path: string;
|
storage_path: string;
|
||||||
mimetype: string;
|
mimetype: string;
|
||||||
@@ -732,6 +734,16 @@ export interface Asset {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssetImportHistoryItem {
|
||||||
|
zip_batch_id: string;
|
||||||
|
source_zip_name: string;
|
||||||
|
english_level?: string | null;
|
||||||
|
sam_plan_id?: number | null;
|
||||||
|
sam_course_id?: number | null;
|
||||||
|
asset_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssetFilters {
|
export interface AssetFilters {
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
course_id?: string;
|
course_id?: string;
|
||||||
@@ -1117,6 +1129,7 @@ export const cmsApi = {
|
|||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return apiFetch(`/api/assets${query ? `?${query}` : ''}`);
|
return apiFetch(`/api/assets${query ? `?${query}` : ''}`);
|
||||||
},
|
},
|
||||||
|
getAssetImportHistory: (): Promise<AssetImportHistoryItem[]> => apiFetch('/api/assets/import-history'),
|
||||||
getCourseAssets: (courseId: string): Promise<Asset[]> => apiFetch(`/api/assets?course_id=${courseId}`),
|
getCourseAssets: (courseId: string): Promise<Asset[]> => apiFetch(`/api/assets?course_id=${courseId}`),
|
||||||
deleteAsset: (id: string): Promise<void> => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }),
|
deleteAsset: (id: string): Promise<void> => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }),
|
||||||
ingestAssetForRag: (id: string): Promise<AssetRagIngestResult> =>
|
ingestAssetForRag: (id: string): Promise<AssetRagIngestResult> =>
|
||||||
|
|||||||
Reference in New Issue
Block a user