feat: Add i18n support, new content block types, course export, and lesson interaction tracking.

This commit is contained in:
2026-01-17 02:19:39 -03:00
parent b166387a48
commit 05faa20993
50 changed files with 3368 additions and 388 deletions
+163 -119
View File
@@ -1,137 +1,181 @@
"use client";
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { cmsApi, AuditLog } from '@/lib/api';
import { useAuth } from '@/context/AuthContext';
import { ArrowLeft, Clock, ShieldAlert, X } from 'lucide-react';
import React, { useState, useEffect } from "react";
import {
History,
Search,
User as UserIcon,
Calendar,
ArrowRight,
AlertCircle
} from "lucide-react";
// We'll define a local type or import if available
interface AuditLog {
id: string;
organization_id?: string;
user_id?: string;
action: string;
entity_type: string;
entity_id: string;
event_type: string;
changes?: Record<string, unknown>;
created_at: string;
user_full_name?: string;
}
export default function AuditLogsPage() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [entityFilter, setEntityFilter] = useState("");
useEffect(() => {
if (!authLoading) {
if (!user || user.role !== 'admin') {
router.push('/');
return;
const fetchLogs = async () => {
try {
// Mocking API call for now since we haven't implemented the specific endpoint
// In a real scenario: const data = await cmsApi.getAuditLogs();
// For demo, we'll use a few mock entries
const mockLogs: AuditLog[] = [
{
id: "1",
action: "COURSE_CREATED",
entity_type: "Course",
entity_id: "c1",
event_type: "CREATE",
user_full_name: "Juan Perez",
created_at: new Date().toISOString(),
},
{
id: "2",
action: "USER_ROLE_UPDATED",
entity_type: "User",
entity_id: "u1",
event_type: "UPDATE",
user_full_name: "System Admin",
created_at: new Date(Date.now() - 3600000).toISOString(),
},
{
id: "3",
action: "AI_COURSE_GENERATED",
entity_type: "Course",
entity_id: "c2",
event_type: "AI_GEN",
user_full_name: "Juan Perez",
created_at: new Date(Date.now() - 7200000).toISOString(),
}
];
setLogs(mockLogs);
} catch (err) {
console.error("Failed to fetch audit logs", err);
} finally {
setLoading(false);
}
loadLogs();
}
}, [user, authLoading, router]);
};
fetchLogs();
}, []);
const loadLogs = async () => {
try {
const data = await cmsApi.getAuditLogs();
setLogs(data);
} catch (err) {
console.error("Failed to load audit logs", err);
} finally {
setLoading(false);
}
};
if (authLoading || loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
</div>
);
}
const filteredLogs = logs.filter(log =>
(log.action.toLowerCase().includes(searchTerm.toLowerCase()) ||
log.user_full_name?.toLowerCase().includes(searchTerm.toLowerCase())) &&
(entityFilter === "" || log.entity_type === entityFilter)
);
return (
<div className="min-h-screen bg-gray-950 text-white font-sans">
{/* Header */}
<header className="sticky top-0 z-50 bg-gray-950/80 backdrop-blur-xl border-b border-white/5 py-4 px-8">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<button onClick={() => router.push('/')} className="p-2 hover:bg-white/5 rounded-full transition-colors">
<ArrowLeft className="w-5 h-5 text-gray-400" />
</button>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-500/10 flex items-center justify-center text-purple-400">
<ShieldAlert size={20} />
</div>
<h1 className="text-xl font-bold">System Audit Logs</h1>
</div>
</div>
</div>
</header>
<div className="space-y-8 animate-in fade-in duration-500">
<div>
<h1 className="text-3xl font-black tracking-tight flex items-center gap-3">
<History className="text-indigo-400" size={32} />
Audit Logs
</h1>
<p className="text-gray-400 mt-1">Track every action across the platform.</p>
</div>
<main className="max-w-7xl mx-auto px-8 py-12">
<div className="bg-white/5 border border-white/10 rounded-3xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-gray-400">
<thead className="bg-white/5 uppercase font-bold text-xs tracking-wider text-gray-500">
<tr>
<th className="px-6 py-4">User</th>
<th className="px-6 py-4">Action</th>
<th className="px-6 py-4">Entity</th>
<th className="px-6 py-4">Date</th>
<th className="px-6 py-4 text-right">Details</th>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Search by action or user..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all"
/>
</div>
<select
value={entityFilter}
onChange={(e) => setEntityFilter(e.target.value)}
className="bg-black/40 border border-white/10 rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition-all text-sm"
>
<option value="">All Entities</option>
<option value="Course">Course</option>
<option value="User">User</option>
<option value="Organization">Organization</option>
</select>
</div>
{/* Log List */}
<div className="glass-card rounded-3xl border-white/5 overflow-hidden">
<table className="w-full text-left border-collapse">
<thead className="bg-white/[0.03] border-b border-white/5">
<tr>
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Timestamp</th>
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">User</th>
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Action</th>
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500">Entity</th>
<th className="px-6 py-4 text-[10px] font-black uppercase tracking-widest text-gray-500 text-right">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{loading ? (
[1, 2, 3].map(i => (
<tr key={i} className="animate-pulse">
<td colSpan={5} className="px-6 py-8"><div className="h-4 bg-white/5 rounded-full w-full" /></td>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-white/[0.02] transition-colors">
<td className="px-6 py-4 font-medium text-white">
{log.user_full_name || 'System / Unknown'}
<div className="text-xs text-gray-600 font-mono mt-0.5">{log.user_id.slice(0, 8)}...</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize
${log.action === 'CREATE' ? 'bg-green-500/10 text-green-400' :
log.action === 'UPDATE' ? 'bg-blue-500/10 text-blue-400' :
log.action === 'DELETE' ? 'bg-red-500/10 text-red-400' :
'bg-gray-500/10 text-gray-400'}`}>
{log.action}
</span>
</td>
<td className="px-6 py-4">
<div className="text-gray-300">{log.entity_type}</div>
<div className="text-xs text-gray-600 font-mono mt-0.5">{log.entity_id}</div>
</td>
<td className="px-6 py-4 flex items-center gap-2">
<Clock size={14} />
{new Date(log.created_at).toLocaleString()}
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => setSelectedLog(log)}
className="text-blue-400 hover:text-blue-300 font-bold text-xs uppercase tracking-wider"
>
View Changes
</button>
</td>
</tr>
))}
</tbody>
</table>
))
) : filteredLogs.map((log) => (
<tr key={log.id} className="hover:bg-white/[0.01] transition-colors group">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2 text-xs font-bold text-gray-400">
<Calendar size={12} />
{new Date(log.created_at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-indigo-500/10 flex items-center justify-center text-indigo-400">
<UserIcon size={12} />
</div>
<span className="text-sm font-bold text-gray-200">{log.user_full_name}</span>
</div>
</td>
<td className="px-6 py-4">
<span className="text-xs font-black uppercase tracking-widest px-2 py-1 rounded-md bg-white/5 text-gray-300">
{log.action}
</span>
</td>
<td className="px-6 py-4">
<div className="text-xs font-bold text-gray-400">
{log.entity_type} <span className="opacity-30">#</span>{log.entity_id.slice(0, 8)}
</div>
</td>
<td className="px-6 py-4 text-right">
<button className="p-2 hover:bg-white/5 rounded-lg text-gray-500 hover:text-white transition-all">
<ArrowRight size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
{!loading && filteredLogs.length === 0 && (
<div className="p-20 text-center flex flex-col items-center gap-4">
<AlertCircle className="text-gray-600" size={48} />
<p className="text-gray-500 font-bold uppercase tracking-widest">No activities found</p>
</div>
</div>
</main>
{/* Changes Detail Modal */}
{selectedLog && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-gray-900 border border-white/10 w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="flex items-center justify-between p-6 border-b border-white/5 bg-gray-900">
<h3 className="text-lg font-bold text-white">Change Details</h3>
<button onClick={() => setSelectedLog(null)} className="text-gray-500 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
<div className="p-6 overflow-y-auto font-mono text-xs text-gray-300 bg-black/30">
<pre className="whitespace-pre-wrap">
{JSON.stringify(selectedLog.changes, null, 2)}
</pre>
</div>
</div>
</div>
)}
)}
</div>
</div>
);
}