feat: agregar historial de importación de ZIP y metadatos a los activos
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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';
|
||||
|
||||
export default function AdminSharedMaterialsPage() {
|
||||
@@ -31,6 +31,7 @@ export default function AdminSharedMaterialsPage() {
|
||||
rag_background_started?: boolean;
|
||||
rag_background_items?: number;
|
||||
} | null>(null);
|
||||
const [importHistory, setImportHistory] = useState<AssetImportHistoryItem[]>([]);
|
||||
|
||||
const canUpload = useMemo(() => Boolean(zipFile) && !loading, [zipFile, loading]);
|
||||
|
||||
@@ -62,6 +63,7 @@ export default function AdminSharedMaterialsPage() {
|
||||
|
||||
React.useEffect(() => {
|
||||
questionBankApi.getMySQLPlans().then(setPlans).catch(() => setPlans([]));
|
||||
cmsApi.getAssetImportHistory().then(setImportHistory).catch(() => setImportHistory([]));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -165,6 +167,7 @@ export default function AdminSharedMaterialsPage() {
|
||||
useDevProcessing,
|
||||
);
|
||||
setResult(response);
|
||||
cmsApi.getAssetImportHistory().then(setImportHistory).catch(() => setImportHistory([]));
|
||||
setPhase('done');
|
||||
alert('Importacion ZIP finalizada.');
|
||||
} catch (error) {
|
||||
@@ -415,6 +418,48 @@ export default function AdminSharedMaterialsPage() {
|
||||
)}
|
||||
</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 && (
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Search, Image as ImageIcon, FileText, Film, File as FileIcon,
|
||||
Loader2, Upload, Trash2, ExternalLink, Filter, Plus
|
||||
@@ -11,38 +11,70 @@ import { useTranslation } from "@/context/I18nContext";
|
||||
import PageLayout from "@/components/PageLayout";
|
||||
|
||||
export default function AssetLibraryPage() {
|
||||
const PAGE_SIZE = 100;
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
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 [filterType, setFilterType] = useState<string>("all");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const loadAssets = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
const loadAssets = useCallback(async (nextPage = 1, append = false) => {
|
||||
if (append) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
try {
|
||||
const filters: AssetFilters = {};
|
||||
const filters: AssetFilters = { page: nextPage, limit: PAGE_SIZE };
|
||||
if (searchTerm) filters.search = searchTerm;
|
||||
if (filterType !== "all") filters.mimetype = filterType;
|
||||
|
||||
const data = await cmsApi.getAssets(filters);
|
||||
setAssets(data);
|
||||
setAssets((prev) => append ? [...prev, ...data] : data);
|
||||
setPage(nextPage);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
} catch (error) {
|
||||
console.error("Failed to load assets:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (append) {
|
||||
setIsLoadingMore(false);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [searchTerm, filterType]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
loadAssets();
|
||||
setAssets([]);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
loadAssets(1, false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [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 files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -56,7 +88,7 @@ export default function AssetLibraryPage() {
|
||||
setUploadProgress(pct);
|
||||
});
|
||||
}
|
||||
loadAssets();
|
||||
loadAssets(1, false);
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
alert("Failed to upload assets.");
|
||||
@@ -237,6 +269,20 @@ export default function AssetLibraryPage() {
|
||||
))}
|
||||
</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>
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
@@ -725,6 +725,8 @@ export interface Asset {
|
||||
uploaded_by: string | null;
|
||||
course_id: string | null;
|
||||
english_level?: string | null;
|
||||
zip_batch_id?: string | null;
|
||||
source_zip_name?: string | null;
|
||||
filename: string;
|
||||
storage_path: string;
|
||||
mimetype: string;
|
||||
@@ -732,6 +734,16 @@ export interface Asset {
|
||||
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 {
|
||||
mimetype?: string;
|
||||
course_id?: string;
|
||||
@@ -1117,6 +1129,7 @@ export const cmsApi = {
|
||||
const query = params.toString();
|
||||
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}`),
|
||||
deleteAsset: (id: string): Promise<void> => apiFetch(`/api/assets/${id}`, { method: 'DELETE' }),
|
||||
ingestAssetForRag: (id: string): Promise<AssetRagIngestResult> =>
|
||||
|
||||
Reference in New Issue
Block a user