Update activity tracking

This commit is contained in:
DariusIII
2025-10-28 11:19:47 +01:00
parent e2afa6ed9e
commit f12f554234
9 changed files with 371 additions and 495 deletions
@@ -48,109 +48,48 @@ class AdminPageController extends BasePageController
}
/**
* Get recent user activity (registrations, deletions, activations, role changes)
* Get recent user activity from the user_activities table
*/
protected function getRecentUserActivity(): array
{
$activities = [];
// Get newly registered users (last 7 days)
$newUsers = \App\Models\User::select('id', 'username', 'created_at')
->where('created_at', '>=', now()->subDays(7))
->orderBy('created_at', 'desc')
$activities = \App\Models\UserActivity::orderBy('created_at', 'desc')
->limit(10)
->get();
foreach ($newUsers as $user) {
$activities[] = (object) [
'type' => 'registration',
'message' => "New user registered: {$user->username}",
'icon' => 'user-plus',
'icon_bg' => 'bg-green-100 dark:bg-green-900',
'icon_color' => 'text-green-600 dark:text-green-400',
'created_at' => $user->created_at,
'username' => $user->username,
return $activities->map(function ($activity) {
return (object) [
'type' => $activity->activity_type,
'message' => $activity->description,
'icon' => $activity->icon,
'icon_bg' => 'bg-'.$activity->color_class.'-100 dark:bg-'.$activity->color_class.'-900',
'icon_color' => 'text-'.$activity->color_class.'-600 dark:text-'.$activity->color_class.'-400',
'created_at' => $activity->created_at,
'username' => $activity->username,
];
}
})->toArray();
}
// Get recently deleted users (last 7 days) - only soft deleted
$deletedUsers = \App\Models\User::onlyTrashed()
->select('id', 'username', 'deleted_at')
->where('deleted_at', '>=', now()->subDays(7))
->orderBy('deleted_at', 'desc')
->limit(10)
->get();
/**
* API endpoint to get recent user activity (for auto-refresh)
*/
public function getRecentActivity()
{
$activities = $this->getRecentUserActivity();
foreach ($deletedUsers as $user) {
$activities[] = (object) [
'type' => 'deletion',
'message' => "User deleted: {$user->username}",
'icon' => 'user-minus',
'icon_bg' => 'bg-red-100 dark:bg-red-900',
'icon_color' => 'text-red-600 dark:text-red-400',
'created_at' => $user->deleted_at,
'username' => $user->username,
];
}
// Get recently activated users (last 7 days)
$activatedUsers = \App\Models\User::select('id', 'username', 'email_verified_at')
->whereNotNull('email_verified_at')
->where('email_verified_at', '>=', now()->subDays(7))
->orderBy('email_verified_at', 'desc')
->limit(10)
->get();
foreach ($activatedUsers as $user) {
$activities[] = (object) [
'type' => 'activation',
'message' => "User activated: {$user->username}",
'icon' => 'user-check',
'icon_bg' => 'bg-blue-100 dark:bg-blue-900',
'icon_color' => 'text-blue-600 dark:text-blue-400',
'created_at' => $user->email_verified_at,
'username' => $user->username,
];
}
// Get users with recent role changes (last 7 days)
$roleChanges = \App\Models\User::select('id', 'username', 'rolechangedate', 'roles_id')
->whereNotNull('rolechangedate')
->where('rolechangedate', '>=', now()->subDays(7))
->orderBy('rolechangedate', 'desc')
->limit(10)
->get();
foreach ($roleChanges as $user) {
// Get role name safely
$roleName = 'Unknown';
if ($user->roles_id) {
try {
$role = \Spatie\Permission\Models\Role::find($user->roles_id);
$roleName = $role ? $role->name : 'Unknown';
} catch (\Exception $e) {
$roleName = 'Role #'.$user->roles_id;
}
}
$activities[] = (object) [
'type' => 'role_change',
'message' => "User role changed: {$user->username}{$roleName}",
'icon' => 'user-tag',
'icon_bg' => 'bg-purple-100 dark:bg-purple-900',
'icon_color' => 'text-purple-600 dark:text-purple-400',
'created_at' => \Carbon\Carbon::parse($user->rolechangedate),
'username' => $user->username,
];
}
// Sort all activities by date (most recent first)
usort($activities, function ($a, $b) {
return $b->created_at <=> $a->created_at;
});
// Return only the 10 most recent
return array_slice($activities, 0, 10);
return response()->json([
'success' => true,
'activities' => array_map(function ($activity) {
return [
'type' => $activity->type,
'message' => $activity->message,
'icon' => $activity->icon,
'icon_bg' => $activity->icon_bg,
'icon_color' => $activity->icon_color,
'created_at' => $activity->created_at->diffForHumans(),
'username' => $activity->username,
];
}, $activities),
]);
}
/**
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserActivity extends Model
{
const UPDATED_AT = null;
protected $fillable = [
'user_id',
'username',
'activity_type',
'description',
'metadata',
];
protected $casts = [
'metadata' => 'array',
'created_at' => 'datetime',
];
/**
* Get icon class based on activity type
*/
public function getIconAttribute(): string
{
return match ($this->activity_type) {
'registered' => 'user-plus',
'deleted' => 'user-times',
'role_updated' => 'user-shield',
default => 'info-circle',
};
}
/**
* Get color class based on activity type
*/
public function getColorClassAttribute(): string
{
return match ($this->activity_type) {
'registered' => 'green',
'deleted' => 'red',
'role_updated' => 'blue',
default => 'gray',
};
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace App\Observers;
use App\Models\User;
use App\Models\UserActivity;
class UserActivityObserver
{
/**
* Handle the User "created" event.
*/
public function created(User $user): void
{
UserActivity::create([
'user_id' => $user->id,
'username' => $user->username,
'activity_type' => 'registered',
'description' => "New user registered: {$user->username}",
'metadata' => [
'email' => $user->email,
'ip_address' => request()->ip(),
],
]);
}
/**
* Handle the User "updated" event.
*/
public function updated(User $user): void
{
// Check if the role was changed
if ($user->isDirty('roles_id')) {
$oldRoleId = $user->getOriginal('roles_id');
$newRoleId = $user->roles_id;
// Get role names
$oldRoleName = $this->getRoleName($oldRoleId);
$newRoleName = $this->getRoleName($newRoleId);
UserActivity::create([
'user_id' => $user->id,
'username' => $user->username,
'activity_type' => 'role_updated',
'description' => "User role updated: {$user->username}{$newRoleName}",
'metadata' => [
'old_role_id' => $oldRoleId,
'new_role_id' => $newRoleId,
'old_role_name' => $oldRoleName,
'new_role_name' => $newRoleName,
'updated_by' => auth()->user()?->username ?? 'System',
],
]);
}
}
/**
* Handle the User "deleted" event (soft delete).
*/
public function deleted(User $user): void
{
// Only log if it's a soft delete (not force delete)
if ($user->trashed()) {
UserActivity::create([
'user_id' => $user->id,
'username' => $user->username,
'activity_type' => 'deleted',
'description' => "User deleted: {$user->username}",
'metadata' => [
'email' => $user->email,
'deleted_by' => auth()->user()?->username ?? 'System',
],
]);
}
}
/**
* Handle the User "restored" event.
*/
public function restored(User $user): void
{
UserActivity::create([
'user_id' => $user->id,
'username' => $user->username,
'activity_type' => 'registered',
'description' => "User restored: {$user->username}",
'metadata' => [
'restored_by' => auth()->user()?->username ?? 'System',
],
]);
}
/**
* Handle the User "force deleted" event.
*/
public function forceDeleted(User $user): void
{
// Log permanent deletion
UserActivity::create([
'user_id' => null, // User no longer exists
'username' => $user->username,
'activity_type' => 'deleted',
'description' => "User permanently deleted: {$user->username}",
'metadata' => [
'email' => $user->email,
'deleted_by' => auth()->user()?->username ?? 'System',
'permanent' => true,
],
]);
}
/**
* Get role name by ID
*/
private function getRoleName(?int $roleId): string
{
if (! $roleId) {
return 'None';
}
try {
$role = \Spatie\Permission\Models\Role::find($roleId);
return $role ? $role->name : "Role #{$roleId}";
} catch (\Exception $e) {
return "Role #{$roleId}";
}
}
}
+2
View File
@@ -3,6 +3,7 @@
namespace App\Providers;
use App\Models\User;
use App\Observers\UserActivityObserver;
use App\Observers\UserServiceObserver;
use Illuminate\Support\ServiceProvider;
@@ -22,5 +23,6 @@ class UserServiceProvider extends ServiceProvider
public function boot(): void
{
User::observe(UserServiceObserver::class);
User::observe(UserActivityObserver::class);
}
}
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_activities', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('username', 255);
$table->string('activity_type', 50); // 'registered', 'deleted', 'role_updated'
$table->text('description');
$table->json('metadata')->nullable(); // Additional data like old/new role
$table->timestamp('created_at')->useCurrent();
$table->index(['activity_type', 'created_at']);
$table->index('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_activities');
}
};
+32 -1
View File
@@ -1,12 +1,43 @@
/* Admin Dashboard - Recent Activity Styles */
.activity-item {
transition: background-color 0.2s ease-in-out;
transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.activity-item:hover {
transform: translateX(2px);
}
/* Activity refresh animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.activity-item {
animation: fadeIn 0.3s ease-in-out;
}
/* Recent activity loading state */
#recent-activity-container.loading {
opacity: 0.6;
pointer-events: none;
}
/* Last updated timestamp subtle animation */
#activity-last-updated {
transition: color 0.3s ease-in-out;
}
#activity-last-updated:hover {
color: rgb(99, 102, 241); /* indigo-500 */
}
/* Chart Container Responsive Sizing */
.chart-container {
position: relative;
+80 -395
View File
@@ -27,6 +27,7 @@ document.addEventListener('DOMContentLoaded', function() {
initAdminGroups();
initTinyMCE();
initAdminSpecificFeatures();
initRecentActivityRefresh();
// Initialize page-specific functionality from inline scripts
initMyMovies();
@@ -3263,7 +3264,8 @@ function startSystemMetricsAutoRefresh() {
const timestampElem = document.querySelector('[data-metric="last-updated"]');
if (timestampElem) {
const now = new Date();
timestampElem.textContent = now.toLocaleTimeString();
const timeString = now.toLocaleTimeString();
timestampElem.textContent = 'Last updated: ' + timeString;
}
})
.catch(error => {
@@ -4255,419 +4257,102 @@ function initAddToCart() {
};
}
// Note: Main DOMContentLoaded event listener is already defined at the top of the file
// Just adding the new initializations to the existing initialization list above
// Show validation error message for deleted users page
window.showValidationError = function(message) {
const errorDiv = document.getElementById('validationError');
const errorMessage = document.getElementById('validationErrorMessage');
if (errorDiv && errorMessage) {
errorMessage.textContent = message;
errorDiv.classList.remove('hidden');
// Scroll to the error message
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
// Auto-hide after 5 seconds
setTimeout(() => {
errorDiv.classList.add('hidden');
}, 5000);
}
};
// Hide validation error
window.hideValidationError = function() {
const errorDiv = document.getElementById('validationError');
if (errorDiv) {
errorDiv.classList.add('hidden');
}
};
// Confirm bulk action for deleted users
window.confirmBulkAction = function(event) {
const action = document.getElementById('bulkAction')?.value;
const checkedBoxes = document.querySelectorAll('.user-checkbox:checked');
if (!action) {
event.preventDefault();
showValidationError('Please select an action from the dropdown.');
return false;
// Recent Activity Auto-Refresh (20 minutes)
function initRecentActivityRefresh() {
const activityContainer = document.getElementById('recent-activity-container');
if (!activityContainer) {
return; // Not on admin dashboard page
}
if (checkedBoxes.length === 0) {
event.preventDefault();
showValidationError('Please select at least one user to perform this action.');
return false;
const refreshUrl = activityContainer.getAttribute('data-refresh-url');
if (!refreshUrl) {
return;
}
// Show confirmation dialog with appropriate message
let confirmMessage = '';
if (action === 'delete') {
confirmMessage = `Are you sure you want to PERMANENTLY delete ${checkedBoxes.length} user(s)?\n\nThis action cannot be undone!`;
} else if (action === 'restore') {
confirmMessage = `Are you sure you want to restore ${checkedBoxes.length} user(s)?`;
}
if (!confirm(confirmMessage)) {
event.preventDefault();
return false;
}
return true;
};
// Movies Layout Toggle Functionality
function initMoviesLayoutToggle() {
const layoutToggle = document.getElementById('layoutToggle');
const layoutToggleText = document.getElementById('layoutToggleText');
const moviesGrid = document.getElementById('moviesGrid');
if (!layoutToggle || !layoutToggleText || !moviesGrid) {
return; // Not on movies page
}
// Get saved layout preference from data attribute (from database)
// 1 = 1-column, 2 = 2-columns
let currentLayout = parseInt(moviesGrid.dataset.userLayout) || 2;
// Apply the saved layout on page load
applyLayout(currentLayout);
// Toggle layout on button click
layoutToggle.addEventListener('click', function() {
if (currentLayout === 2) {
currentLayout = 1;
} else {
currentLayout = 2;
}
// Save preference to database via AJAX
fetch('/movies/update-layout', {
method: 'POST',
// Function to refresh activity data
function refreshRecentActivity() {
fetch(refreshUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
},
body: JSON.stringify({
layout: currentLayout
})
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Apply layout after successful save
applyLayout(currentLayout);
if (data.success && data.activities) {
updateActivityDisplay(data.activities);
updateLastRefreshTime();
}
})
.catch(error => {
console.error('Error saving layout preference:', error);
// Still apply layout even if save failed
applyLayout(currentLayout);
console.error('Error refreshing recent activity:', error);
});
});
function applyLayout(layout) {
// Get all movie poster images and placeholder divs
const posterImages = moviesGrid.querySelectorAll('img[alt], .bg-gray-200.dark\\:bg-gray-700');
// Get all release card containers
const releaseCardContainers = moviesGrid.querySelectorAll('.release-card-container');
if (layout === 1) {
// 1-column layout: larger images (w-48 h-72, original size)
moviesGrid.classList.remove('lg:grid-cols-2');
moviesGrid.classList.add('grid-cols-1');
layoutToggleText.textContent = '1 Column';
layoutToggle.querySelector('i').className = 'fas fa-th-list mr-2';
// Update image sizes to larger
posterImages.forEach(el => {
el.classList.remove('w-32', 'h-48');
el.classList.add('w-48', 'h-72');
});
// Update release card layout for 1-column view - side by side on all screens
releaseCardContainers.forEach(container => {
// Change container to flex-row with items on sides
container.classList.remove('space-y-2');
container.classList.add('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3');
// Make info wrapper take available space
const infoWrapper = container.querySelector('.release-info-wrapper');
if (infoWrapper) {
infoWrapper.classList.add('flex-1', 'min-w-0');
}
// Make actions wrapper stay on the right in a horizontal row
const actionsWrapper = container.querySelector('.release-actions');
if (actionsWrapper) {
actionsWrapper.classList.remove('flex-wrap');
actionsWrapper.classList.add('flex-shrink-0', 'flex-row', 'items-center');
}
});
} else {
// 2-column layout: smaller images (w-32 h-48)
moviesGrid.classList.remove('grid-cols-1');
moviesGrid.classList.add('lg:grid-cols-2');
layoutToggleText.textContent = '2 Columns';
layoutToggle.querySelector('i').className = 'fas fa-th-large mr-2';
// Update image sizes to smaller
posterImages.forEach(el => {
el.classList.remove('w-48', 'h-72');
el.classList.add('w-32', 'h-48');
});
// Update release card layout for 2-column view - stacked vertically
releaseCardContainers.forEach(container => {
// Change back to stacked layout
container.classList.add('space-y-2');
container.classList.remove('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3');
// Remove special styling from info wrapper
const infoWrapper = container.querySelector('.release-info-wrapper');
if (infoWrapper) {
infoWrapper.classList.remove('flex-1', 'min-w-0');
}
// Restore normal flex-wrap for actions wrapper
const actionsWrapper = container.querySelector('.release-actions');
if (actionsWrapper) {
actionsWrapper.classList.add('flex-wrap', 'items-center');
actionsWrapper.classList.remove('flex-shrink-0', 'flex-row');
}
});
}
}
}
/**
* Profile Charts - User Download and API Request Charts
* Initialize charts, progress bars, and tab switching for profile page
*/
// Store chart instances globally to prevent recreation
let profileDownloadsChartInstance = null;
let profileApiRequestsChartInstance = null;
function initProfileCharts() {
initProfileProgressBars();
// Note: initProfileChartsData() is called when the API tab is shown for the first time
}
/**
* Initialize progress bar animations
*/
function initProfileProgressBars() {
const progressBars = document.querySelectorAll('.progress-bar');
progressBars.forEach(bar => {
const width = bar.dataset.width;
if (width) {
// Small delay to trigger CSS transition
setTimeout(() => {
bar.style.width = width + '%';
}, 100);
}
});
}
/**
* Initialize Chart.js charts for profile page
*/
function initProfileChartsData() {
// Get data from data attributes
const chartContainer = document.getElementById('profile-charts-container');
if (!chartContainer) return;
const downloadsHourlyData = JSON.parse(chartContainer.dataset.downloadsHourly || '{}');
const apiRequestsHourlyData = JSON.parse(chartContainer.dataset.apiRequestsHourly || '{}');
const downloadLimit = parseInt(chartContainer.dataset.downloadLimit || '0');
const apiLimit = parseInt(chartContainer.dataset.apiLimit || '0');
const labels = Object.keys(downloadsHourlyData);
const downloadsValues = Object.values(downloadsHourlyData);
const apiValues = Object.values(apiRequestsHourlyData);
// Check if user is in dark mode
const isDarkMode = document.documentElement.classList.contains('dark');
const gridColor = isDarkMode ? 'rgba(75, 85, 99, 0.3)' : 'rgba(200, 200, 200, 0.3)';
const textColor = isDarkMode ? 'rgba(156, 163, 175, 1)' : 'rgba(75, 85, 99, 1)';
// Create Downloads Chart
createProfileDownloadsChart(labels, downloadsValues, downloadLimit, gridColor, textColor);
// Create API Requests Chart
createProfileApiRequestsChart(labels, apiValues, apiLimit, gridColor, textColor);
}
/**
* Create Downloads Chart for profile page
*/
function createProfileDownloadsChart(labels, data, limit, gridColor, textColor) {
const downloadsCtx = document.getElementById('downloadsChart');
if (!downloadsCtx) return;
// Destroy existing chart instance if it exists
if (profileDownloadsChartInstance) {
profileDownloadsChartInstance.destroy();
}
profileDownloadsChartInstance = new Chart(downloadsCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Downloads',
data: data,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: 'rgb(59, 130, 246)',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5
}, {
label: 'Limit',
data: Array(labels.length).fill(limit),
borderColor: 'rgba(239, 68, 68, 0.5)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
pointRadius: 0,
pointHoverRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
labels: {
color: textColor
}
},
tooltip: {
mode: 'index',
intersect: false,
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: textColor
},
grid: {
color: gridColor
}
},
x: {
ticks: {
color: textColor,
maxRotation: 45,
minRotation: 45
},
grid: {
color: gridColor
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
// Function to update the activity display
function updateActivityDisplay(activities) {
const container = document.getElementById('recent-activity-container');
if (!container) return;
// Clear existing content
container.innerHTML = '';
if (activities.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-center py-4" id="no-activity-message">No recent activity</p>';
return;
}
});
}
/**
* Create API Requests Chart for profile page
*/
function createProfileApiRequestsChart(labels, data, limit, gridColor, textColor) {
const apiCtx = document.getElementById('apiRequestsChart');
if (!apiCtx) return;
// Add each activity item
activities.forEach(activity => {
const activityItem = document.createElement('div');
activityItem.className = 'flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors';
// Destroy existing chart instance if it exists
if (profileApiRequestsChartInstance) {
profileApiRequestsChartInstance.destroy();
activityItem.innerHTML = `
<div class="w-8 h-8 ${activity.icon_bg} rounded-full flex items-center justify-center mr-3 flex-shrink-0">
<i class="fas fa-${activity.icon} ${activity.icon_color} text-sm"></i>
</div>
<div class="flex-1">
<p class="text-sm text-gray-800 dark:text-gray-200">${escapeHtml(activity.message)}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">${escapeHtml(activity.created_at)}</p>
</div>
`;
container.appendChild(activityItem);
});
}
profileApiRequestsChartInstance = new Chart(apiCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'API Requests',
data: data,
borderColor: 'rgb(168, 85, 247)',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: 'rgb(168, 85, 247)',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5
}, {
label: 'Limit',
data: Array(labels.length).fill(limit),
borderColor: 'rgba(239, 68, 68, 0.5)',
borderWidth: 2,
borderDash: [5, 5],
fill: false,
pointRadius: 0,
pointHoverRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
labels: {
color: textColor
}
},
tooltip: {
mode: 'index',
intersect: false,
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1,
color: textColor
},
grid: {
color: gridColor
}
},
x: {
ticks: {
color: textColor,
maxRotation: 45,
minRotation: 45
},
grid: {
color: gridColor
}
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
// Function to update last refresh time
function updateLastRefreshTime() {
const lastUpdatedElement = document.getElementById('activity-last-updated');
if (lastUpdatedElement) {
const now = new Date();
const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
lastUpdatedElement.innerHTML = `<i class="fas fa-sync-alt"></i> Last updated: ${timeString}`;
}
}
// Helper function to escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Set up auto-refresh every 20 minutes (1200000 milliseconds)
const refreshInterval = 20 * 60 * 1000; // 20 minutes in milliseconds
setInterval(refreshRecentActivity, refreshInterval);
// Also refresh on page visibility change (when user comes back to tab)
document.addEventListener('visibilitychange', function() {
if (!document.hidden) {
refreshRecentActivity();
}
});
}
+10 -5
View File
@@ -442,11 +442,16 @@
<!-- Recent Activity -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 border border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
<i class="fas fa-history mr-2 text-indigo-600 dark:text-indigo-400"></i>
Recent User Activity
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center justify-between">
<span>
<i class="fas fa-history mr-2 text-indigo-600 dark:text-indigo-400"></i>
Recent User Activity
</span>
<span class="text-xs text-gray-500 dark:text-gray-400" id="activity-last-updated">
<i class="fas fa-sync-alt"></i> Auto-refreshes every 20 minutes
</span>
</h3>
<div class="space-y-3">
<div class="space-y-3" id="recent-activity-container" data-refresh-url="{{ route('admin.api.user-activity.recent') }}">
@if(isset($recent_activity) && count($recent_activity) > 0)
@foreach($recent_activity as $activity)
<div class="flex items-start activity-item rounded-lg p-2 hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors">
@@ -460,7 +465,7 @@
</div>
@endforeach
@else
<p class="text-gray-500 dark:text-gray-400 text-center py-4">No recent activity</p>
<p class="text-gray-500 dark:text-gray-400 text-center py-4" id="no-activity-message">No recent activity</p>
@endif
</div>
</div>
+1
View File
@@ -181,6 +181,7 @@ Route::middleware('role:Admin', '2fa')->prefix('admin')->group(function () {
// User Activity API endpoints
Route::get('api/user-activity/minutes', [AdminPageController::class, 'getUserActivityMinutes'])->name('admin.api.user-activity.minutes');
Route::get('api/user-activity/recent', [AdminPageController::class, 'getRecentActivity'])->name('admin.api.user-activity.recent');
Route::post('anidb-delete/{id}', [AdminAnidbController::class, 'destroy'])->name('admin.anidb-delete');
Route::match(['GET', 'POST'], 'anidb-edit/{id}', [AdminAnidbController::class, 'edit'])->name('admin.anidb-edit');