Switch PHP views to Twig and add 2FA/UI enhancements
Migrate many view templates from raw PHP to Twig and modernize UI/UX for 2FA and settings. Controllers updated to provide avatar data and two-factor info (ProfileController, UserController) and SettingsController now includes timezone lists, notification preset selection, cron path, cached update state and rollback availability. ErrorHandler now attempts to render error pages via a new Core\TwigService with a safe fallback to raw PHP views. TwoFactorService generation silences deprecated warnings during QR code creation. Numerous .php view files were removed and replaced with .twig equivalents (2fa setup/verify/backup-codes and many auth, dashboard, domains, errors, layout, users, tags, tld-registry, etc.), and core/TwigService was added. These changes move the app toward a Twig-based templating system, improve 2FA flows, surface avatar images in lists/profiles, and make error rendering more robust.
This commit is contained in:
@@ -8,6 +8,7 @@ use App\Models\User;
|
||||
use App\Models\SessionManager;
|
||||
use App\Models\RememberToken;
|
||||
use App\Services\Logger;
|
||||
use App\Services\TwoFactorService;
|
||||
use App\Helpers\AvatarHelper;
|
||||
|
||||
class ProfileController extends Controller
|
||||
@@ -71,10 +72,30 @@ class ProfileController extends Controller
|
||||
// Format sessions for display (adds deviceIcon, browserInfo, timeAgo, sessionAge)
|
||||
$formattedSessions = \App\Helpers\SessionHelper::formatForDisplay($sessions);
|
||||
|
||||
// Avatar data
|
||||
$avatar = AvatarHelper::getAvatar($user, 80);
|
||||
|
||||
// 2FA status
|
||||
$twoFactorService = new TwoFactorService();
|
||||
$twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
|
||||
|
||||
$backupCodes = !empty($user['two_factor_backup_codes'])
|
||||
? json_decode($user['two_factor_backup_codes'], true)
|
||||
: [];
|
||||
|
||||
$twoFactorStatus = [
|
||||
'enabled' => !empty($user['two_factor_enabled']),
|
||||
'setup_at' => $user['two_factor_setup_at'] ?? null,
|
||||
'backup_codes_count' => is_array($backupCodes) ? count($backupCodes) : 0,
|
||||
'required' => $twoFactorService->isTwoFactorRequired($userId),
|
||||
];
|
||||
|
||||
$this->view('profile/index', [
|
||||
'user' => $user,
|
||||
'sessions' => $formattedSessions,
|
||||
'userModel' => $this->userModel,
|
||||
'avatar' => $avatar,
|
||||
'twoFactorStatus' => $twoFactorStatus,
|
||||
'twoFactorPolicy' => $twoFactorPolicy,
|
||||
'title' => 'My Profile'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,72 @@ class SettingsController extends Controller
|
||||
// Status notification triggers
|
||||
$statusTriggers = $this->settingModel->getNotificationStatusTriggers();
|
||||
|
||||
// Timezone lists for the Application tab
|
||||
$popularTimezones = [
|
||||
'UTC' => 'UTC',
|
||||
'America/New_York' => 'Eastern Time (US)',
|
||||
'America/Chicago' => 'Central Time (US)',
|
||||
'America/Denver' => 'Mountain Time (US)',
|
||||
'America/Los_Angeles' => 'Pacific Time (US)',
|
||||
'Europe/London' => 'London',
|
||||
'Europe/Paris' => 'Paris',
|
||||
'Asia/Tokyo' => 'Tokyo',
|
||||
'Australia/Sydney' => 'Sydney'
|
||||
];
|
||||
$allTimezones = timezone_identifiers_list();
|
||||
|
||||
// Determine which notification preset is selected
|
||||
$currentNotificationDays = $settings['notification_days_before'] ?? '30,15,7,3,1';
|
||||
$selectedPreset = 'custom';
|
||||
foreach ($notificationPresets as $key => $preset) {
|
||||
if ($preset['value'] === $currentNotificationDays) {
|
||||
$selectedPreset = $key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cron path for System tab
|
||||
$cronPath = realpath(defined('PATH_ROOT') ? PATH_ROOT . 'cron/check_domains.php' : __DIR__ . '/../../cron/check_domains.php') ?: 'cron/check_domains.php';
|
||||
|
||||
// Cached update state for Updates tab
|
||||
$cachedUpdateAvailable = false;
|
||||
$cachedUpdateData = null;
|
||||
$currentVer = $appSettings['app_version'] ?? '0';
|
||||
$latestVer = $updateSettings['latest_available_version'] ?? null;
|
||||
$updateChannel = $updateSettings['update_channel'] ?? 'stable';
|
||||
$commitsBehind = (int)($updateSettings['commits_behind_count'] ?? 0);
|
||||
$installedSha = $updateSettings['installed_commit_sha'] ?? '';
|
||||
$remoteSha = $updateSettings['latest_remote_sha'] ?? '';
|
||||
if ($installedSha !== '' && $remoteSha !== '' && str_starts_with($installedSha, $remoteSha)) {
|
||||
$commitsBehind = 0;
|
||||
}
|
||||
if ($latestVer && version_compare($latestVer, $currentVer, '>')) {
|
||||
$cachedUpdateAvailable = true;
|
||||
$cachedUpdateData = [
|
||||
'available' => true,
|
||||
'type' => 'release',
|
||||
'current_version' => $currentVer,
|
||||
'latest_version' => $latestVer,
|
||||
'release_notes' => $updateSettings['latest_release_notes'] ?? '',
|
||||
'release_url' => $updateSettings['latest_release_url'] ?? '',
|
||||
'published_at' => $updateSettings['latest_release_published_at'] ?? null,
|
||||
'channel' => $updateChannel,
|
||||
];
|
||||
} elseif ($updateChannel === 'latest' && $commitsBehind > 0) {
|
||||
$cachedUpdateAvailable = true;
|
||||
$cachedUpdateData = [
|
||||
'available' => true,
|
||||
'type' => 'hotfix',
|
||||
'current_version' => $currentVer,
|
||||
'commits_behind' => $commitsBehind,
|
||||
'commit_messages' => [],
|
||||
'channel' => $updateChannel,
|
||||
];
|
||||
}
|
||||
|
||||
// Rollback availability
|
||||
$rollbackAvailable = !empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path']);
|
||||
|
||||
$this->view('settings/index', [
|
||||
'settings' => $settings,
|
||||
'appSettings' => $appSettings,
|
||||
@@ -81,6 +147,13 @@ class SettingsController extends Controller
|
||||
'notificationPresets' => $notificationPresets,
|
||||
'checkIntervalPresets' => $checkIntervalPresets,
|
||||
'statusTriggers' => $statusTriggers,
|
||||
'popularTimezones' => $popularTimezones,
|
||||
'allTimezones' => $allTimezones,
|
||||
'selectedPreset' => $selectedPreset,
|
||||
'cronPath' => $cronPath,
|
||||
'cachedUpdateAvailable' => $cachedUpdateAvailable,
|
||||
'cachedUpdateData' => $cachedUpdateData,
|
||||
'rollbackAvailable' => $rollbackAvailable,
|
||||
'title' => 'Settings'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ class UserController extends Controller
|
||||
|
||||
// Get filtered users
|
||||
$users = $this->userModel->getFiltered($filters, $sort, strtoupper($order), $perPage, $offset);
|
||||
|
||||
foreach ($users as &$u) {
|
||||
$u['avatar'] = \App\Helpers\AvatarHelper::getAvatar($u, 40);
|
||||
}
|
||||
unset($u);
|
||||
|
||||
$this->view('users/index', [
|
||||
'users' => $users,
|
||||
@@ -240,6 +245,17 @@ class UserController extends Controller
|
||||
// Get 2FA status
|
||||
$twoFactorStatus = $this->userModel->getTwoFactorStatus($userId);
|
||||
|
||||
// Avatar for profile header
|
||||
$userAvatar = \App\Helpers\AvatarHelper::getAvatar($user, 64);
|
||||
|
||||
// Registrar distribution
|
||||
$registrarCounts = [];
|
||||
foreach ($domains as $d) {
|
||||
$reg = !empty($d['registrar']) ? $d['registrar'] : 'Unknown';
|
||||
$registrarCounts[$reg] = ($registrarCounts[$reg] ?? 0) + 1;
|
||||
}
|
||||
arsort($registrarCounts);
|
||||
|
||||
$this->view('users/show', [
|
||||
'title' => htmlspecialchars($user['full_name']) . ' - User Profile',
|
||||
'user' => $user,
|
||||
@@ -248,6 +264,8 @@ class UserController extends Controller
|
||||
'tags' => $tags,
|
||||
'groups' => $groups,
|
||||
'twoFactorStatus' => $twoFactorStatus,
|
||||
'userAvatar' => $userAvatar,
|
||||
'registrarCounts' => $registrarCounts,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -297,7 +297,29 @@ class ErrorHandler
|
||||
$session_data = json_decode($errorData['session_data'], true);
|
||||
|
||||
// Display debug page in development, clean 500 in production
|
||||
if ($this->isDevelopment) {
|
||||
$twigTemplate = $this->isDevelopment ? 'errors/debug.twig' : 'errors/500.twig';
|
||||
$twigFile = __DIR__ . '/../Views/' . $twigTemplate;
|
||||
|
||||
if (file_exists($twigFile)) {
|
||||
try {
|
||||
$memory_usage_mb = round(($memory_usage ?? 0) / 1024 / 1024, 2);
|
||||
$peak_memory_mb = round(memory_get_peak_usage(true) / 1024 / 1024, 2);
|
||||
$errorContext = compact(
|
||||
'error_id', 'error_type', 'error_message', 'error_file', 'error_line',
|
||||
'stack_trace', 'request_method', 'request_uri', 'user_agent',
|
||||
'ip_address', 'php_version', 'memory_usage', 'memory_usage_mb',
|
||||
'peak_memory_mb', 'occurred_at', 'user_info', 'request_data', 'session_data'
|
||||
);
|
||||
echo \Core\TwigService::getInstance()->render($twigTemplate, $errorContext);
|
||||
} catch (\Throwable $e) {
|
||||
// Twig itself failed — fall back to raw PHP view
|
||||
if ($this->isDevelopment) {
|
||||
require __DIR__ . '/../Views/errors/debug.php';
|
||||
} else {
|
||||
require __DIR__ . '/../Views/errors/500.php';
|
||||
}
|
||||
}
|
||||
} elseif ($this->isDevelopment) {
|
||||
require __DIR__ . '/../Views/errors/debug.php';
|
||||
} else {
|
||||
require __DIR__ . '/../Views/errors/500.php';
|
||||
|
||||
@@ -38,14 +38,20 @@ class TwoFactorService
|
||||
*/
|
||||
public function generateQrCodeDataUri(string $email, string $secret, string $appName = 'Domain Monitor'): string
|
||||
{
|
||||
$qrCode = new QrCode($this->google2fa->getQRCodeUrl($appName, $email, $secret));
|
||||
$qrCode->setSize(200);
|
||||
$qrCode->setMargin(10);
|
||||
|
||||
$writer = new PngWriter();
|
||||
$result = $writer->write($qrCode);
|
||||
|
||||
return 'data:image/png;base64,' . base64_encode($result->getString());
|
||||
$previousLevel = error_reporting(error_reporting() & ~E_DEPRECATED);
|
||||
|
||||
try {
|
||||
$qrCode = new QrCode($this->google2fa->getQRCodeUrl($appName, $email, $secret));
|
||||
$qrCode->setSize(200);
|
||||
$qrCode->setMargin(10);
|
||||
|
||||
$writer = new PngWriter();
|
||||
$result = $writer->write($qrCode);
|
||||
|
||||
return 'data:image/png;base64,' . base64_encode($result->getString());
|
||||
} finally {
|
||||
error_reporting($previousLevel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<?php
|
||||
$title = '2FA Backup Codes';
|
||||
$pageTitle = '2FA Backup Codes';
|
||||
$pageDescription = 'Save these backup codes in a safe place';
|
||||
$pageIcon = 'fas fa-key';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-900">2FA Backup Codes</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Save these codes in a safe place - they can be used to access your account if you lose your authenticator device</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Warning -->
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-red-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="text-sm font-medium text-red-800">Important Security Notice</h4>
|
||||
<p class="text-sm text-red-700 mt-1">
|
||||
These backup codes are shown only once. Each code can only be used once. Store them securely and never share them with anyone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-md font-semibold text-gray-900">Your Backup Codes</h4>
|
||||
<button onclick="printCodes()" class="text-sm text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-print mr-1"></i>
|
||||
Print Codes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3" id="backup-codes">
|
||||
<?php foreach ($backupCodes as $index => $code): ?>
|
||||
<div class="flex items-center justify-between bg-gray-50 p-3 rounded-lg border">
|
||||
<code class="font-mono text-sm text-gray-900"><?= htmlspecialchars($code) ?></code>
|
||||
<button onclick="copyCode('<?= htmlspecialchars($code) ?>', this)"
|
||||
class="ml-2 px-2 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-blue-800 mb-2">How to use backup codes:</h4>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• When logging in, enter a backup code instead of your 2FA code</li>
|
||||
<li>• Each backup code can only be used once</li>
|
||||
<li>• After using a code, it will be automatically removed from your account</li>
|
||||
<li>• If you run out of backup codes, you'll need to disable and re-enable 2FA</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<a href="/profile" class="text-sm text-gray-600 hover:text-gray-500">
|
||||
<i class="fas fa-arrow-left mr-1"></i>
|
||||
Back to Profile
|
||||
</a>
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="downloadCodes()"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Download
|
||||
</button>
|
||||
<a href="/"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
I've Saved These Codes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyCode(code, button) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
showCopySuccess(button);
|
||||
}).catch(() => {
|
||||
fallbackCopyTextToClipboard(code);
|
||||
});
|
||||
} else {
|
||||
fallbackCopyTextToClipboard(code);
|
||||
}
|
||||
}
|
||||
|
||||
function showCopySuccess(button) {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
button.classList.remove('bg-gray-200', 'hover:bg-gray-300', 'text-gray-700');
|
||||
button.classList.add('bg-green-500', 'text-white');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('bg-green-500', 'text-white');
|
||||
button.classList.add('bg-gray-200', 'hover:bg-gray-300', 'text-gray-700');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function fallbackCopyTextToClipboard(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.top = '0';
|
||||
textArea.style.left = '0';
|
||||
textArea.style.width = '2em';
|
||||
textArea.style.height = '2em';
|
||||
textArea.style.padding = '0';
|
||||
textArea.style.border = 'none';
|
||||
textArea.style.outline = 'none';
|
||||
textArea.style.boxShadow = 'none';
|
||||
textArea.style.background = 'transparent';
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
alert('Code copied to clipboard!');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
alert('Failed to copy code');
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function printCodes() {
|
||||
const printWindow = window.open('', '_blank');
|
||||
const codes = <?= json_encode($backupCodes) ?>;
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>2FA Backup Codes - <?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; line-height: 1.6; }
|
||||
h1 { color: #333; margin-bottom: 20px; }
|
||||
.codes { background: #f5f5f5; padding: 15px; border: 1px solid #ddd; margin: 20px 0; }
|
||||
.code { margin: 5px 0; font-family: monospace; }
|
||||
.warning { background: #fee; border: 1px solid #fcc; padding: 10px; margin: 10px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>2FA Backup Codes</h1>
|
||||
<p><strong>Account:</strong> <?= htmlspecialchars($user['full_name'] ?? $user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)</p>
|
||||
<p><strong>Generated:</strong> ${new Date().toLocaleString()}</p>
|
||||
|
||||
<div class="warning">
|
||||
<strong>Important:</strong> Store these codes in a safe place. Each code can only be used once.
|
||||
</div>
|
||||
|
||||
<div class="codes">
|
||||
${codes.map((code, index) => `<div class="code">${index + 1}. ${code}</div>`).join('')}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
|
||||
function downloadCodes() {
|
||||
const codes = <?= json_encode($backupCodes) ?>;
|
||||
const content = `2FA Backup Codes
|
||||
Account: <?= htmlspecialchars($user['full_name'] ?? $user['username']) ?> (<?= htmlspecialchars($user['email']) ?>)
|
||||
Generated: ${new Date().toLocaleString()}
|
||||
|
||||
IMPORTANT: Store these codes in a safe place. Each code can only be used once.
|
||||
|
||||
${codes.map((code, index) => `${index + 1}. ${code}`).join('\n')}
|
||||
|
||||
If you lose access to your authenticator app, you can use these codes to log in.
|
||||
Generate new codes if you run out or if you suspect they've been compromised.`;
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '2fa-backup-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
124
app/Views/2fa/backup-codes.twig
Normal file
124
app/Views/2fa/backup-codes.twig
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = '2FA Backup Codes' %}
|
||||
{% set pageTitle = '2FA Backup Codes' %}
|
||||
{% set pageDescription = 'Save these backup codes in a safe place' %}
|
||||
{% set pageIcon = 'fas fa-key' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="bg-white dark:bg-slate-800 shadow rounded-lg border border-gray-200 dark:border-slate-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">2FA Backup Codes</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Save these codes in a safe place - they can be used to access your account if you lose your authenticator device</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
{# Warning #}
|
||||
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-triangle text-red-400 dark:text-red-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="text-sm font-medium text-red-800 dark:text-red-300">Important Security Notice</h4>
|
||||
<p class="text-sm text-red-700 dark:text-red-400/80 mt-1">
|
||||
These backup codes are shown only once. Each code can only be used once. Store them securely and never share them with anyone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Backup Codes #}
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-md font-semibold text-gray-900 dark:text-white">Your Backup Codes</h4>
|
||||
<button onclick="printCodes()" class="text-sm text-primary hover:text-primary-dark dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<i class="fas fa-print mr-1"></i>Print Codes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3" id="backup-codes">
|
||||
{% for code in backupCodes %}
|
||||
<div class="flex items-center justify-between bg-gray-50 dark:bg-slate-700/50 p-3 rounded-lg border border-gray-200 dark:border-slate-600">
|
||||
<code class="font-mono text-sm text-gray-900 dark:text-slate-200">{{ code }}</code>
|
||||
<button onclick="copyCode('{{ code }}', this)" class="ml-2 px-2 py-1 text-xs bg-gray-200 dark:bg-slate-600 text-gray-700 dark:text-slate-300 rounded hover:bg-gray-300 dark:hover:bg-slate-500 transition-colors">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Instructions #}
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-lg p-4 mb-6">
|
||||
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">How to use backup codes:</h4>
|
||||
<ul class="text-sm text-blue-700 dark:text-blue-400/80 space-y-1">
|
||||
<li>• When logging in, enter a backup code instead of your 2FA code</li>
|
||||
<li>• Each backup code can only be used once</li>
|
||||
<li>• After using a code, it will be automatically removed from your account</li>
|
||||
<li>• If you run out of backup codes, you'll need to disable and re-enable 2FA</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<a href="/profile" class="text-sm text-gray-600 dark:text-slate-400 hover:text-gray-500 dark:hover:text-slate-300">
|
||||
<i class="fas fa-arrow-left mr-1"></i>Back to Profile
|
||||
</a>
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="downloadCodes()" class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 text-sm font-medium rounded-md text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 transition-colors">
|
||||
<i class="fas fa-download mr-2"></i>Download
|
||||
</button>
|
||||
<a href="/" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-check mr-2"></i>I've Saved These Codes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const backupCodesData = {{ backupCodes|json_encode|raw }};
|
||||
const userName = '{{ user.full_name|default(user.username) }}';
|
||||
const userEmail = '{{ user.email }}';
|
||||
|
||||
function copyCode(code, button) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
button.classList.replace('bg-gray-200', 'bg-green-500');
|
||||
button.classList.replace('text-gray-700', 'text-white');
|
||||
button.classList.replace('dark:bg-slate-600', 'dark:bg-green-600');
|
||||
button.classList.replace('dark:text-slate-300', 'dark:text-white');
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.replace('bg-green-500', 'bg-gray-200');
|
||||
button.classList.replace('text-white', 'text-gray-700');
|
||||
button.classList.replace('dark:bg-green-600', 'dark:bg-slate-600');
|
||||
button.classList.replace('dark:text-white', 'dark:text-slate-300');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function printCodes() {
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`<!DOCTYPE html><html><head><title>2FA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:20px}.codes{background:#f5f5f5;padding:15px;border:1px solid #ddd;margin:20px 0}.code{margin:5px 0;font-family:monospace}</style>
|
||||
</head><body><h1>2FA Backup Codes</h1><p><strong>Account:</strong> ${userName} (${userEmail})</p><p><strong>Generated:</strong> ${new Date().toLocaleString()}</p>
|
||||
<div class="codes">${backupCodesData.map((c,i) => '<div class="code">'+(i+1)+'. '+c+'</div>').join('')}</div></body></html>`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
|
||||
function downloadCodes() {
|
||||
const content = `2FA Backup Codes\nAccount: ${userName} (${userEmail})\nGenerated: ${new Date().toLocaleString()}\n\n${backupCodesData.map((c,i) => (i+1)+'. '+c).join('\n')}`;
|
||||
const blob = new Blob([content], {type: 'text/plain'});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = '2fa-backup-codes.txt';
|
||||
a.click();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?php
|
||||
$title = 'Setup Two-Factor Authentication';
|
||||
$pageTitle = 'Setup 2FA';
|
||||
$pageDescription = 'Configure two-factor authentication for your account';
|
||||
$pageIcon = 'fas fa-shield-alt';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-shield-alt text-gray-400 mr-2 text-sm"></i>
|
||||
Setup Two-Factor Authentication
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Step 1: Download Authenticator App -->
|
||||
<div class="border-l-4 border-blue-500 pl-4">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-2">Step 1: Install an Authenticator App</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">Download one of these apps on your mobile device:</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<i class="fab fa-google text-2xl text-blue-600 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900">Google Authenticator</p>
|
||||
<p class="text-xs text-gray-500">iOS & Android</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<i class="fas fa-mobile-alt text-2xl text-blue-600 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900">Authy</p>
|
||||
<p class="text-xs text-gray-500">iOS & Android</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<i class="fab fa-microsoft text-2xl text-blue-600 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900">Microsoft Authenticator</p>
|
||||
<p class="text-xs text-gray-500">iOS & Android</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Scan QR Code -->
|
||||
<div class="border-l-4 border-green-500 pl-4">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-2">Step 2: Scan QR Code</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">Open your authenticator app and scan this QR code:</p>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-blue-600 mr-2"></i>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Note:</strong> This QR code will remain the same even if you refresh the page.
|
||||
Once you scan it, you can enter the verification code below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="bg-white border-2 border-gray-200 rounded-lg p-4">
|
||||
<img src="<?= htmlspecialchars($qrCodeUrl) ?>" alt="QR Code for 2FA setup" class="w-48 h-48">
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 mb-2">Can't scan? Enter this code manually:</p>
|
||||
<div class="bg-gray-100 rounded-lg p-3 font-mono text-sm">
|
||||
<code class="text-gray-800"><?= htmlspecialchars($secret) ?></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Verify Code -->
|
||||
<div class="border-l-4 border-yellow-500 pl-4">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-2">Step 3: Verify Setup</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">Enter the 6-digit code from your authenticator app:</p>
|
||||
|
||||
<form method="POST" action="/2fa/verify-setup" id="verifyForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<div class="max-w-xs mx-auto">
|
||||
<input type="text"
|
||||
name="verification_code"
|
||||
id="verification_code"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
placeholder="123456"
|
||||
class="w-full px-4 py-3 text-center text-2xl font-mono border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
required>
|
||||
<p class="text-xs text-gray-500 mt-2 text-center">Enter 6-digit code</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Verify & Enable 2FA
|
||||
</button>
|
||||
<a href="/2fa/cancel-setup"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Security Notice -->
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-900">Important Security Notice</p>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
Once 2FA is enabled, you'll need your authenticator app to log in.
|
||||
Make sure to save your backup codes in a secure location.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const codeInput = document.getElementById('verification_code');
|
||||
|
||||
// Auto-focus on code input
|
||||
codeInput.focus();
|
||||
|
||||
// Only allow digits
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
this.value = this.value.replace(/[^0-9]/g, '');
|
||||
});
|
||||
|
||||
// Auto-submit when 6 digits are entered
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
if (this.value.length === 6) {
|
||||
// Small delay to let user see the complete code
|
||||
setTimeout(() => {
|
||||
document.getElementById('verifyForm').submit();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('verifyForm').addEventListener('submit', function(e) {
|
||||
const code = codeInput.value.trim();
|
||||
if (code.length !== 6 || !/^\d{6}$/.test(code)) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a valid 6-digit code');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
116
app/Views/2fa/setup.twig
Normal file
116
app/Views/2fa/setup.twig
Normal file
@@ -0,0 +1,116 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Setup Two-Factor Authentication' %}
|
||||
{% set pageTitle = 'Setup 2FA' %}
|
||||
{% set pageDescription = 'Configure two-factor authentication for your account' %}
|
||||
{% set pageIcon = 'fas fa-shield-alt' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-shield-alt text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
|
||||
Setup Two-Factor Authentication
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-5">
|
||||
{# Step 1: Download Authenticator App #}
|
||||
<div class="border-l-4 border-blue-500 pl-4">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">Step 1: Install an Authenticator App</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-3">Download one of these apps on your mobile device:</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3 text-center">
|
||||
<i class="fab fa-google text-2xl text-blue-600 dark:text-blue-400 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Google Authenticator</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">iOS & Android</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3 text-center">
|
||||
<i class="fas fa-mobile-alt text-2xl text-blue-600 dark:text-blue-400 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Authy</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">iOS & Android</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3 text-center">
|
||||
<i class="fab fa-microsoft text-2xl text-blue-600 dark:text-blue-400 mb-2"></i>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Microsoft Authenticator</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">iOS & Android</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Step 2: Scan QR Code #}
|
||||
<div class="border-l-4 border-green-500 pl-4">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">Step 2: Scan QR Code</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Open your authenticator app and scan this QR code:</p>
|
||||
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="bg-white border-2 border-gray-200 dark:border-slate-600 rounded-lg p-4">
|
||||
<img src="{{ qrCodeUrl }}" alt="QR Code for 2FA setup" class="w-48 h-48">
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mb-2">Can't scan? Enter this code manually:</p>
|
||||
<div class="bg-gray-100 dark:bg-slate-700 rounded-lg p-3 font-mono text-sm">
|
||||
<code class="text-gray-800 dark:text-slate-200">{{ secret }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Step 3: Verify Code #}
|
||||
<div class="border-l-4 border-yellow-500 pl-4">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">Step 3: Verify Setup</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Enter the 6-digit code from your authenticator app:</p>
|
||||
|
||||
<form method="POST" action="/2fa/verify-setup" id="verifyForm">
|
||||
{{ csrf_field() }}
|
||||
|
||||
<div class="max-w-xs mx-auto">
|
||||
<input type="text" name="verification_code" id="verification_code" maxlength="6" pattern="[0-9]{6}" placeholder="123456"
|
||||
class="w-full px-4 py-3 text-center text-2xl font-mono border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-primary focus:border-primary" required>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2 text-center">Enter 6-digit code</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||
<button type="submit" class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium text-sm">
|
||||
<i class="fas fa-check mr-2"></i>Verify & Enable 2FA
|
||||
</button>
|
||||
<a href="/2fa/cancel-setup" class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 text-sm">
|
||||
<i class="fas fa-times mr-2"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Security Notice #}
|
||||
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-900 dark:text-yellow-300">Important Security Notice</p>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-400/80 mt-1">
|
||||
Once 2FA is enabled, you'll need your authenticator app to log in.
|
||||
Make sure to save your backup codes in a secure location.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const codeInput = document.getElementById('verification_code');
|
||||
codeInput.focus();
|
||||
codeInput.addEventListener('input', function() {
|
||||
this.value = this.value.replace(/[^0-9]/g, '');
|
||||
if (this.value.length === 6) {
|
||||
setTimeout(() => document.getElementById('verifyForm').submit(), 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,206 +0,0 @@
|
||||
<?php
|
||||
$title = '2FA Verification';
|
||||
ob_start();
|
||||
|
||||
$twoFactorService = new \App\Services\TwoFactorService();
|
||||
$canSendEmailCode = $user['email_verified'] && $twoFactorService->checkRateLimit($_SERVER['REMOTE_ADDR'] ?? '', $user['id']);
|
||||
?>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary bg-opacity-10 mb-4">
|
||||
<i class="fas fa-shield-alt text-primary text-xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">
|
||||
2FA Verification
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
Hello, <strong><?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></strong>!<br>
|
||||
Please enter your 2FA code to complete login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
<span class="text-sm text-green-700"><?= htmlspecialchars($_SESSION['success']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['success']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<form class="space-y-4" method="POST" action="/2fa/verify" id="verifyForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<!-- Security verification completed during login -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
<span class="text-sm text-green-700">Security verification completed during login</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
2FA Code
|
||||
</label>
|
||||
<input id="code" name="verification_code" type="text" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-center text-lg font-mono tracking-widest focus:ring-2 focus:ring-primary focus:border-primary transition-colors"
|
||||
placeholder="000000" maxlength="8" autocomplete="one-time-code" autofocus>
|
||||
<p class="text-xs text-gray-500 mt-1 text-center">Enter 6-digit code from your authenticator app, email code, or 8-character backup code</p>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-primary text-white py-2 px-4 rounded-lg hover:bg-primary-dark focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-colors">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Verify Code
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<?php if ($canSendEmailCode): ?>
|
||||
<button type="button" onclick="sendEmailCode()"
|
||||
class="text-primary hover:text-primary-dark transition-colors">
|
||||
<i class="fas fa-envelope mr-1"></i>
|
||||
Send Email Code
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<span class="text-gray-400">
|
||||
<i class="fas fa-envelope mr-1"></i>
|
||||
Email code unavailable
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<a href="/logout" class="text-gray-600 hover:text-gray-500 transition-colors">
|
||||
<i class="fas fa-sign-out-alt mr-1"></i>
|
||||
Sign out instead
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-4 border-t border-gray-200">
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
Having trouble? You can also use a backup code or contact your administrator for help.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function sendEmailCode() {
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Sending...';
|
||||
|
||||
fetch('/2fa/send-email-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btn.innerHTML = '<i class="fas fa-check mr-1"></i>Code Sent';
|
||||
btn.classList.remove('text-primary', 'hover:text-primary-dark');
|
||||
btn.classList.add('text-green-600');
|
||||
|
||||
// Reset button after 30 seconds
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('text-green-600');
|
||||
btn.classList.add('text-primary', 'hover:text-primary-dark');
|
||||
}, 30000);
|
||||
} else {
|
||||
alert('Failed to send email code: ' + (data.error || 'Unknown error'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Failed to send email code');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const codeInput = document.getElementById('code');
|
||||
const form = document.getElementById('verifyForm');
|
||||
|
||||
// Auto-focus on code input
|
||||
codeInput.focus();
|
||||
|
||||
// Handle input validation and auto-submit
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
// Allow digits, letters for backup codes
|
||||
this.value = this.value.replace(/[^A-Za-z0-9]/g, '');
|
||||
|
||||
// Auto-submit when 6 digits are entered (TOTP/email codes)
|
||||
if (this.value.length === 6 && /^\d{6}$/.test(this.value)) {
|
||||
setTimeout(() => {
|
||||
form.submit();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Auto-submit when 8 characters are entered (backup codes)
|
||||
if (this.value.length === 8 && /^[A-Z0-9]{8}$/i.test(this.value)) {
|
||||
setTimeout(() => {
|
||||
form.submit();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Form validation
|
||||
form.addEventListener('submit', function(e) {
|
||||
const code = codeInput.value.trim();
|
||||
|
||||
// Check if code is entered
|
||||
if (!code) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a verification code');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate code format
|
||||
if (code.length === 6 && !/^\d{6}$/.test(code)) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a valid 6-digit code');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code.length === 8 && !/^[A-Z0-9]{8}$/i.test(code)) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a valid 8-character backup code');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code.length < 6 || code.length > 8) {
|
||||
e.preventDefault();
|
||||
alert('Please enter a valid verification code (6 digits or 8 characters)');
|
||||
codeInput.focus();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../auth/base-auth.php';
|
||||
?>
|
||||
124
app/Views/2fa/verify.twig
Normal file
124
app/Views/2fa/verify.twig
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends 'auth/base-auth.twig' %}
|
||||
|
||||
{% set title = '2FA Verification' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary bg-opacity-10 dark:bg-primary/20 mb-4">
|
||||
<i class="fas fa-shield-alt text-primary text-xl"></i>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">2FA Verification</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Hello, <strong class="text-gray-900 dark:text-white">{{ user.full_name|default(user.username) }}</strong>!<br>
|
||||
Please enter your 2FA code to complete login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if flash.success is defined %}
|
||||
<div class="mb-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">{{ flash.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if flash.error is defined %}
|
||||
<div class="mb-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 dark:text-red-400 mr-2"></i>
|
||||
<span class="text-sm text-red-700 dark:text-red-300">{{ flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="space-y-4" method="POST" action="/2fa/verify" id="verifyForm">
|
||||
{{ csrf_field() }}
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">Security verification completed during login</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">2FA Code</label>
|
||||
<input id="code" name="verification_code" type="text" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-center text-lg font-mono tracking-widest bg-white dark:bg-slate-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
placeholder="000000" maxlength="8" autocomplete="one-time-code" autofocus>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1 text-center">Enter 6-digit code from your authenticator app, email code, or 8-character backup code</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full bg-primary text-white py-2 px-4 rounded-lg hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-check mr-2"></i>Verify Code
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
{% if canSendEmailCode %}
|
||||
<button type="button" onclick="sendEmailCode()" class="text-primary hover:text-primary-dark dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<i class="fas fa-envelope mr-1"></i>Send Email Code
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-slate-500"><i class="fas fa-envelope mr-1"></i>Email code unavailable</span>
|
||||
{% endif %}
|
||||
<a href="/logout" class="text-gray-600 dark:text-slate-400 hover:text-gray-500 dark:hover:text-slate-300">
|
||||
<i class="fas fa-sign-out-alt mr-1"></i>Sign out instead
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-slate-700 text-center">
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">Having trouble? You can also use a backup code or contact your administrator.</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function sendEmailCode() {
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Sending...';
|
||||
|
||||
fetch('/2fa/send-email-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btn.innerHTML = '<i class="fas fa-check mr-1"></i>Code Sent';
|
||||
btn.classList.add('text-green-600');
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('text-green-600');
|
||||
}, 30000);
|
||||
} else {
|
||||
alert('Failed to send email code: ' + (data.error || 'Unknown error'));
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Failed to send email code');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const codeInput = document.getElementById('code');
|
||||
codeInput.focus();
|
||||
codeInput.addEventListener('input', function() {
|
||||
this.value = this.value.replace(/[^A-Za-z0-9]/g, '');
|
||||
if ((this.value.length === 6 && /^\d{6}$/.test(this.value)) ||
|
||||
(this.value.length === 8 && /^[A-Z0-9]{8}$/i.test(this.value))) {
|
||||
setTimeout(() => document.getElementById('verifyForm').submit(), 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,42 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* CAPTCHA Widget Component
|
||||
* Renders the appropriate CAPTCHA widget based on settings
|
||||
*
|
||||
* Required variables:
|
||||
* - $captchaSettings: Array with 'provider' and 'site_key'
|
||||
*/
|
||||
{#
|
||||
# CAPTCHA Widget Component
|
||||
# Renders the appropriate CAPTCHA widget based on settings
|
||||
#
|
||||
# Required variables:
|
||||
# - captchaSettings: Array with 'provider' and 'site_key'
|
||||
#}
|
||||
|
||||
$provider = $captchaSettings['provider'] ?? 'disabled';
|
||||
$siteKey = $captchaSettings['site_key'] ?? '';
|
||||
{% set provider = captchaSettings.provider|default('disabled') %}
|
||||
{% set siteKey = captchaSettings.site_key|default('') %}
|
||||
|
||||
if ($provider === 'disabled' || empty($siteKey)) {
|
||||
return; // No CAPTCHA to render
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CAPTCHA Widget -->
|
||||
{% if provider != 'disabled' and siteKey is not empty %}
|
||||
{# CAPTCHA Widget #}
|
||||
<div class="captcha-container mb-4">
|
||||
<?php if ($provider === 'recaptcha_v2'): ?>
|
||||
<!-- reCAPTCHA v2 -->
|
||||
<div class="g-recaptcha" data-sitekey="<?= htmlspecialchars($siteKey) ?>"></div>
|
||||
{% if provider == 'recaptcha_v2' %}
|
||||
{# reCAPTCHA v2 #}
|
||||
<div class="g-recaptcha" data-sitekey="{{ siteKey }}"></div>
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
|
||||
<?php elseif ($provider === 'recaptcha_v3'): ?>
|
||||
<!-- reCAPTCHA v3 (Invisible) -->
|
||||
{% elseif provider == 'recaptcha_v3' %}
|
||||
{# reCAPTCHA v3 (Invisible) #}
|
||||
<input type="hidden" id="captcha_response" name="captcha_response">
|
||||
<script src="https://www.google.com/recaptcha/api.js?render=<?= htmlspecialchars($siteKey) ?>"></script>
|
||||
|
||||
<?php elseif ($provider === 'turnstile'): ?>
|
||||
<!-- Cloudflare Turnstile -->
|
||||
<div class="cf-turnstile" data-sitekey="<?= htmlspecialchars($siteKey) ?>"></div>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
<script src="https://www.google.com/recaptcha/api.js?render={{ siteKey }}"></script>
|
||||
|
||||
<?php endif; ?>
|
||||
{% elseif provider == 'turnstile' %}
|
||||
{# Cloudflare Turnstile #}
|
||||
<div class="cf-turnstile" data-sitekey="{{ siteKey }}"></div>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<?php if ($provider === 'recaptcha_v3'): ?>
|
||||
<!-- reCAPTCHA v3 Form Submission Handler -->
|
||||
{% if provider == 'recaptcha_v3' %}
|
||||
{# reCAPTCHA v3 Form Submission Handler #}
|
||||
<script>
|
||||
// Store the original form submission handler
|
||||
const form = document.querySelector('form');
|
||||
@@ -46,7 +40,7 @@ if ($provider === 'disabled' || empty($siteKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
grecaptcha.ready(function() {
|
||||
grecaptcha.execute('<?= htmlspecialchars($siteKey) ?>', {action: 'submit'}).then(function(token) {
|
||||
grecaptcha.execute('{{ siteKey }}', {action: 'submit'}).then(function(token) {
|
||||
document.getElementById('captcha_response').value = token;
|
||||
|
||||
// Call original submit handler if it exists
|
||||
@@ -60,8 +54,8 @@ if ($provider === 'disabled' || empty($siteKey)) {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php elseif ($provider === 'recaptcha_v2' || $provider === 'turnstile'): ?>
|
||||
<!-- reCAPTCHA v2 / Turnstile Response Handler -->
|
||||
{% elseif provider == 'recaptcha_v2' or provider == 'turnstile' %}
|
||||
{# reCAPTCHA v2 / Turnstile Response Handler #}
|
||||
<script>
|
||||
// Add hidden input to capture response
|
||||
const form = document.querySelector('form');
|
||||
@@ -73,14 +67,14 @@ if ($provider === 'disabled' || empty($siteKey)) {
|
||||
|
||||
// Capture response on form submit
|
||||
form.addEventListener('submit', function(e) {
|
||||
<?php if ($provider === 'recaptcha_v2'): ?>
|
||||
{% if provider == 'recaptcha_v2' %}
|
||||
const response = grecaptcha.getResponse();
|
||||
<?php else: // turnstile ?>
|
||||
{% else %}{# turnstile #}
|
||||
const response = turnstile.getResponse();
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
captchaInput.value = response;
|
||||
});
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,56 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $title ?? 'Authentication' ?> - Domain Monitor</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#4A90E2',
|
||||
dark: '#357ABD',
|
||||
light: '#6BA3E8',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full">
|
||||
<!-- Auth Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
|
||||
<?= $content ?>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-6">
|
||||
<p class="text-gray-500 text-xs">
|
||||
© <?= date('Y') ?> <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a>. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($scripts)): ?>
|
||||
<?= $scripts ?>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
209
app/Views/auth/base-auth.twig
Normal file
209
app/Views/auth/base-auth.twig
Normal file
@@ -0,0 +1,209 @@
|
||||
{#
|
||||
# Auth Layout Template
|
||||
# Used for: login, register, forgot-password, verify-email, etc.
|
||||
#}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title|default('Authentication') }} - {{ appSettings.app_name|default('Domain Monitor') }}</title>
|
||||
|
||||
{# Tailwind CSS #}
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
{# Font Awesome #}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#4A90E2',
|
||||
dark: '#357ABD',
|
||||
light: '#6BA3E8',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{# Theme initialization (prevent flash) #}
|
||||
<script>
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
.dark body, html.dark body {
|
||||
background-color: #0f172a;
|
||||
}
|
||||
.bg-waves {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.bg-waves svg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.graph-svg-light { display: block; }
|
||||
.graph-svg-dark { display: none; }
|
||||
.dark .graph-svg-light { display: none; }
|
||||
.dark .graph-svg-dark { display: block; }
|
||||
</style>
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4 bg-slate-100 dark:bg-slate-900 transition-colors duration-200">
|
||||
{# Monitoring Graph Waves Background #}
|
||||
<div class="bg-waves">
|
||||
{# Light Mode SVG #}
|
||||
<svg class="graph-svg-light" viewBox="0 0 100 100" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="waveGradient1" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:0.15"/>
|
||||
<stop offset="100%" style="stop-color:#2563eb;stop-opacity:0.02"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="waveGradient2" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:0.12"/>
|
||||
<stop offset="100%" style="stop-color:#2563eb;stop-opacity:0.01"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="waveGradient3" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:0.08"/>
|
||||
<stop offset="100%" style="stop-color:#2563eb;stop-opacity:0.01"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{# Top accent line #}
|
||||
<path d="M 0,18 Q 15,14 30,20 T 60,16 T 90,22 T 100,18"
|
||||
stroke="#2563eb" stroke-width="0.15" fill="none" opacity="0.4" vector-effect="non-scaling-stroke"/>
|
||||
|
||||
{# Wave 1 - Back #}
|
||||
<path d="M 0,45 Q 20,38 40,48 T 80,42 T 100,50 L 100,100 L 0,100 Z" fill="url(#waveGradient3)"/>
|
||||
<path d="M 0,45 Q 20,38 40,48 T 80,42 T 100,50"
|
||||
stroke="#2563eb" stroke-width="0.2" fill="none" opacity="0.3" vector-effect="non-scaling-stroke"/>
|
||||
|
||||
{# Wave 2 - Middle #}
|
||||
<path d="M 0,55 Q 25,48 50,58 T 100,52 L 100,100 L 0,100 Z" fill="url(#waveGradient2)"/>
|
||||
<path d="M 0,55 Q 25,48 50,58 T 100,52"
|
||||
stroke="#2563eb" stroke-width="0.25" fill="none" opacity="0.5" vector-effect="non-scaling-stroke"/>
|
||||
|
||||
{# Wave 3 - Front #}
|
||||
<path d="M 0,68 Q 30,60 60,70 T 100,64 L 100,100 L 0,100 Z" fill="url(#waveGradient1)"/>
|
||||
<path d="M 0,68 Q 30,60 60,70 T 100,64"
|
||||
stroke="#2563eb" stroke-width="0.3" fill="none" opacity="0.6" vector-effect="non-scaling-stroke"/>
|
||||
|
||||
{# Bottom accent line #}
|
||||
<path d="M 0,82 Q 20,78 40,84 T 80,80 T 100,85"
|
||||
stroke="#2563eb" stroke-width="0.15" fill="none" opacity="0.3" vector-effect="non-scaling-stroke"/>
|
||||
</svg>
|
||||
|
||||
{# Dark Mode SVG #}
|
||||
<svg class="graph-svg-dark" viewBox="0 0 100 100" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="waveGradientDark1" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#60a5fa;stop-opacity:0.2"/>
|
||||
<stop offset="100%" style="stop-color:#60a5fa;stop-opacity:0.02"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="waveGradientDark2" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#60a5fa;stop-opacity:0.15"/>
|
||||
<stop offset="100%" style="stop-color:#60a5fa;stop-opacity:0.01"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="waveGradientDark3" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#60a5fa;stop-opacity:0.1"/>
|
||||
<stop offset="100%" style="stop-color:#60a5fa;stop-opacity:0.01"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{# Top accent line #}
|
||||
<path d="M 0,18 Q 15,14 30,20 T 60,16 T 90,22 T 100,18"
|
||||
stroke="#60a5fa" stroke-width="0.15" fill="none" opacity="0.35" vector-effect="non-scaling-stroke"/>
|
||||
|
||||
{# Wave 1 - Back #}
|
||||
<path d="M 0,45 Q 20,38 40,48 T 80,42 T 100,50 L 100,100 L 0,100 Z" fill="url(#waveGradientDark3)"/>
|
||||
<path d="M 0,45 Q 20,38 40,48 T 80,42 T 100,50"
|
||||
stroke="#60a5fa" stroke-width="0.2" fill="none" opacity="0.25" vector-effect="non-scaling-stroke"/>
|
||||
|
||||
{# Wave 2 - Middle #}
|
||||
<path d="M 0,55 Q 25,48 50,58 T 100,52 L 100,100 L 0,100 Z" fill="url(#waveGradientDark2)"/>
|
||||
<path d="M 0,55 Q 25,48 50,58 T 100,52"
|
||||
stroke="#60a5fa" stroke-width="0.25" fill="none" opacity="0.4" vector-effect="non-scaling-stroke"/>
|
||||
|
||||
{# Wave 3 - Front #}
|
||||
<path d="M 0,68 Q 30,60 60,70 T 100,64 L 100,100 L 0,100 Z" fill="url(#waveGradientDark1)"/>
|
||||
<path d="M 0,68 Q 30,60 60,70 T 100,64"
|
||||
stroke="#60a5fa" stroke-width="0.3" fill="none" opacity="0.5" vector-effect="non-scaling-stroke"/>
|
||||
|
||||
{# Bottom accent line #}
|
||||
<path d="M 0,82 Q 20,78 40,84 T 80,80 T 100,85"
|
||||
stroke="#60a5fa" stroke-width="0.15" fill="none" opacity="0.25" vector-effect="non-scaling-stroke"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{# Logo - Top Left #}
|
||||
<div class="fixed top-4 left-4 z-20 flex items-center gap-3">
|
||||
<img src="{{ base_url }}/assets/logo.svg" alt="{{ appSettings.app_name|default('Domain Monitor') }}" class="h-14 w-auto drop-shadow-md">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xl font-bold text-slate-800 dark:text-white tracking-tight">{{ appSettings.app_name|default('Domain Monitor') }}</span>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400 font-medium">Track your domains</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Theme Toggle Button #}
|
||||
<button onclick="toggleTheme()" id="themeToggle" title="Toggle theme" class="fixed top-4 right-4 z-20 flex items-center justify-center w-10 h-10 text-slate-500 hover:text-slate-700 hover:bg-slate-200 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors duration-150 bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm shadow-md border border-slate-200 dark:border-slate-700">
|
||||
<i class="fas fa-sun dark:hidden"></i>
|
||||
<i class="fas fa-moon hidden dark:inline"></i>
|
||||
</button>
|
||||
|
||||
<div class="max-w-md w-full relative z-10">
|
||||
|
||||
{# Auth Card #}
|
||||
<div class="bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm rounded-xl shadow-lg shadow-slate-200/50 dark:shadow-slate-950/50 border border-slate-200 dark:border-slate-700 p-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div class="text-center mt-6">
|
||||
<p class="text-slate-500 dark:text-slate-400 text-xs">
|
||||
© {{ "now"|date("Y") }} <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150" title="Visit {{ appSettings.app_name|default('Domain Monitor') }} on GitHub">{{ appSettings.app_name|default('Domain Monitor') }}</a>. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme toggle function
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const isDark = html.classList.contains('dark');
|
||||
|
||||
if (isDark) {
|
||||
html.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
html.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
$title = 'Forgot Password';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<!-- Logo and Title -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
|
||||
<i class="fas fa-key text-white text-2xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Forgot Password?</h1>
|
||||
<p class="text-sm text-gray-500">No worries, we'll send you reset instructions</p>
|
||||
</div>
|
||||
|
||||
<!-- Error/Success Alert -->
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="mb-6 bg-green-50 border border-green-200 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
<span class="text-sm text-green-700"><?= htmlspecialchars($_SESSION['success']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['success']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Forgot Password Form -->
|
||||
<form method="POST" action="/forgot-password" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
<!-- Email Field -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Email Address
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-envelope text-gray-400 text-sm"></i>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
autofocus
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Enter your email address">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">Enter the email associated with your account</p>
|
||||
</div>
|
||||
|
||||
<!-- CAPTCHA Widget -->
|
||||
<?php include __DIR__ . '/captcha-widget.php'; ?>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors duration-200 flex items-center justify-center text-sm">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
Send Reset Link
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Back to Login Link -->
|
||||
<div class="text-center mt-6 pt-6 border-t border-gray-200">
|
||||
<a href="/login" class="inline-flex items-center text-sm text-gray-600 hover:text-gray-800">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/base-auth.php';
|
||||
?>
|
||||
82
app/Views/auth/forgot-password.twig
Normal file
82
app/Views/auth/forgot-password.twig
Normal file
@@ -0,0 +1,82 @@
|
||||
{#
|
||||
# Forgot Password Page
|
||||
#}
|
||||
{% extends 'auth/base-auth.twig' %}
|
||||
|
||||
{% set title = 'Forgot Password' %}
|
||||
|
||||
{% block content %}
|
||||
{# Logo and Title #}
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
|
||||
<i class="fas fa-key text-white text-2xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-1">Forgot Password?</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No worries, we'll send you reset instructions</p>
|
||||
</div>
|
||||
|
||||
{# Error Alert #}
|
||||
{% if flash.error is defined %}
|
||||
<div class="mb-6 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 dark:text-red-400 mr-2"></i>
|
||||
<span class="text-sm text-red-700 dark:text-red-300">{{ flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Success Alert #}
|
||||
{% if flash.success is defined %}
|
||||
<div class="mb-6 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">{{ flash.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Forgot Password Form #}
|
||||
<form method="POST" action="/forgot-password" class="space-y-5">
|
||||
{{ csrf_field() }}
|
||||
|
||||
{# Email Field #}
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Email Address
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-envelope text-gray-400 text-sm"></i>
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
autofocus
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Enter your email address">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enter the email associated with your account</p>
|
||||
</div>
|
||||
|
||||
{# CAPTCHA Widget #}
|
||||
{% include 'auth/_captcha-widget.twig' %}
|
||||
|
||||
{# Submit Button #}
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors duration-200 flex items-center justify-center text-sm">
|
||||
<i class="fas fa-paper-plane mr-2"></i>
|
||||
Send Reset Link
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{# Back to Login Link #}
|
||||
<div class="text-center mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="/login" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,34 +1,47 @@
|
||||
<?php
|
||||
$title = 'Login';
|
||||
ob_start();
|
||||
?>
|
||||
{#
|
||||
# Login Page
|
||||
#}
|
||||
{% extends 'auth/base-auth.twig' %}
|
||||
|
||||
<!-- Logo and Title -->
|
||||
{% set title = 'Login' %}
|
||||
|
||||
{% block content %}
|
||||
{# Logo and Title #}
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
|
||||
<i class="fas fa-globe text-white text-2xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Welcome Back</h1>
|
||||
<p class="text-sm text-gray-500">Sign in to access your account</p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-1">Welcome Back</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Sign in to access your account</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
{# Success Alert #}
|
||||
{% if flash.success is defined %}
|
||||
<div class="mb-6 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">{{ flash.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<!-- Login Form -->
|
||||
{# Error Alert #}
|
||||
{% if flash.error is defined %}
|
||||
<div class="mb-6 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 dark:text-red-400 mr-2"></i>
|
||||
<span class="text-sm text-red-700 dark:text-red-300">{{ flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Login Form #}
|
||||
<form method="POST" action="/login" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
<!-- Username Field -->
|
||||
{{ csrf_field() }}
|
||||
|
||||
{# Username Field #}
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Username or Email
|
||||
</label>
|
||||
<div class="relative">
|
||||
@@ -41,14 +54,14 @@ ob_start();
|
||||
name="username"
|
||||
required
|
||||
autofocus
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Enter your username or email">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
{# Password Field #}
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
@@ -60,36 +73,36 @@ ob_start();
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Enter your password">
|
||||
<button
|
||||
type="button"
|
||||
onclick="togglePassword()"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none">
|
||||
<i class="fas fa-eye text-sm" id="toggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
{# Remember Me #}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember"
|
||||
value="1"
|
||||
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
||||
<span class="ml-2 text-sm text-gray-600">Remember me</span>
|
||||
class="w-4 h-4 text-primary border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded focus:ring-primary">
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Remember me</span>
|
||||
</label>
|
||||
<a href="/forgot-password" class="text-sm text-primary hover:text-primary-dark">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- CAPTCHA Widget -->
|
||||
<?php include __DIR__ . '/captcha-widget.php'; ?>
|
||||
{# CAPTCHA Widget #}
|
||||
{% include 'auth/_captcha-widget.twig' %}
|
||||
|
||||
<!-- Submit Button -->
|
||||
{# Submit Button #}
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors duration-200 flex items-center justify-center text-sm">
|
||||
@@ -98,21 +111,20 @@ ob_start();
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<?php if ($registrationEnabled ?? false): ?>
|
||||
<!-- Sign Up Link -->
|
||||
<div class="text-center mt-6 pt-6 border-t border-gray-200">
|
||||
<p class="text-sm text-gray-600">
|
||||
{% if registrationEnabled|default(false) %}
|
||||
{# Sign Up Link #}
|
||||
<div class="text-center mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?
|
||||
<a href="/register" class="text-primary hover:text-primary-dark font-medium">
|
||||
Create Account
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
$scripts = <<<'SCRIPT'
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword() {
|
||||
const passwordInput = document.getElementById('password');
|
||||
@@ -129,6 +141,4 @@ $scripts = <<<'SCRIPT'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
SCRIPT;
|
||||
require __DIR__ . '/base-auth.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
@@ -1,44 +1,47 @@
|
||||
<?php
|
||||
$title = 'Register';
|
||||
ob_start();
|
||||
?>
|
||||
{#
|
||||
# Registration Page
|
||||
#}
|
||||
{% extends 'auth/base-auth.twig' %}
|
||||
|
||||
<!-- Logo and Title -->
|
||||
{% set title = 'Register' %}
|
||||
|
||||
{% block content %}
|
||||
{# Logo and Title #}
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
|
||||
<i class="fas fa-user-plus text-white text-2xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Create Account</h1>
|
||||
<p class="text-sm text-gray-500">Join Domain Monitor today</p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-1">Create Account</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Join Domain Monitor today</p>
|
||||
</div>
|
||||
|
||||
<!-- Error/Success Alert -->
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
{# Error Alert #}
|
||||
{% if flash.error is defined %}
|
||||
<div class="mb-6 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||
<i class="fas fa-exclamation-circle text-red-500 dark:text-red-400 mr-2"></i>
|
||||
<span class="text-sm text-red-700 dark:text-red-300">{{ flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="mb-6 bg-green-50 border border-green-200 p-3 rounded-lg">
|
||||
{# Success Alert #}
|
||||
{% if flash.success is defined %}
|
||||
<div class="mb-6 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
<span class="text-sm text-green-700"><?= htmlspecialchars($_SESSION['success']) ?></span>
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">{{ flash.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['success']); ?>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<!-- Registration Form -->
|
||||
{# Registration Form #}
|
||||
<form method="POST" action="/register" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
<!-- Full Name Field -->
|
||||
{{ csrf_field() }}
|
||||
|
||||
{# Full Name Field #}
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Full Name
|
||||
</label>
|
||||
<div class="relative">
|
||||
@@ -51,14 +54,14 @@ ob_start();
|
||||
name="full_name"
|
||||
required
|
||||
autofocus
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Enter your full name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username Field -->
|
||||
{# Username Field #}
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Username
|
||||
</label>
|
||||
<div class="relative">
|
||||
@@ -71,15 +74,15 @@ ob_start();
|
||||
name="username"
|
||||
required
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Choose a username">
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">Letters, numbers, and underscores only</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Letters, numbers, and underscores only</p>
|
||||
</div>
|
||||
|
||||
<!-- Email Field -->
|
||||
{# Email Field #}
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Email Address
|
||||
</label>
|
||||
<div class="relative">
|
||||
@@ -91,14 +94,14 @@ ob_start();
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="your.email@example.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
{# Password Field #}
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
@@ -111,21 +114,21 @@ ob_start();
|
||||
name="password"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Create a strong password">
|
||||
<button
|
||||
type="button"
|
||||
onclick="togglePassword('password')"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none">
|
||||
<i class="fas fa-eye text-sm" id="toggleIcon-password"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Minimum 8 characters</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password Field -->
|
||||
{# Confirm Password Field #}
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
@@ -138,18 +141,18 @@ ob_start();
|
||||
name="password_confirm"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Re-enter your password">
|
||||
<button
|
||||
type="button"
|
||||
onclick="togglePassword('password_confirm')"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none">
|
||||
<i class="fas fa-eye text-sm" id="toggleIcon-password_confirm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terms Checkbox -->
|
||||
{# Terms Checkbox #}
|
||||
<div class="flex items-start pt-2">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
@@ -157,17 +160,17 @@ ob_start();
|
||||
id="terms"
|
||||
name="terms"
|
||||
required
|
||||
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
||||
class="w-4 h-4 text-primary border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded focus:ring-primary">
|
||||
</div>
|
||||
<label for="terms" class="ml-2 text-xs text-gray-600">
|
||||
<label for="terms" class="ml-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
I agree to the Terms of Service and Privacy Policy
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- CAPTCHA Widget -->
|
||||
<?php include __DIR__ . '/captcha-widget.php'; ?>
|
||||
{# CAPTCHA Widget #}
|
||||
{% include 'auth/_captcha-widget.twig' %}
|
||||
|
||||
<!-- Submit Button -->
|
||||
{# Submit Button #}
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors duration-200 flex items-center justify-center text-sm mt-6">
|
||||
@@ -176,19 +179,18 @@ ob_start();
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Sign In Link -->
|
||||
<div class="text-center mt-6 pt-6 border-t border-gray-200">
|
||||
<p class="text-sm text-gray-600">
|
||||
{# Sign In Link #}
|
||||
<div class="text-center mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Already have an account?
|
||||
<a href="/login" class="text-primary hover:text-primary-dark font-medium">
|
||||
Sign In
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
$scripts = <<<'SCRIPT'
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword(fieldId) {
|
||||
const passwordInput = document.getElementById(fieldId);
|
||||
@@ -216,6 +218,4 @@ $scripts = <<<'SCRIPT'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
SCRIPT;
|
||||
require __DIR__ . '/base-auth.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
@@ -1,37 +1,50 @@
|
||||
<?php
|
||||
$title = 'Reset Password';
|
||||
ob_start();
|
||||
?>
|
||||
{#
|
||||
# Reset Password Page
|
||||
#}
|
||||
{% extends 'auth/base-auth.twig' %}
|
||||
|
||||
<!-- Logo and Title -->
|
||||
{% set title = 'Reset Password' %}
|
||||
|
||||
{% block content %}
|
||||
{# Logo and Title #}
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
|
||||
<i class="fas fa-lock-open text-white text-2xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Reset Password</h1>
|
||||
<p class="text-sm text-gray-500">Enter your new password below</p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-1">Reset Password</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Enter your new password below</p>
|
||||
</div>
|
||||
|
||||
<!-- Error/Success Alert -->
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
{# Success Alert #}
|
||||
{% if flash.success is defined %}
|
||||
<div class="mb-6 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
|
||||
<span class="text-sm text-green-700 dark:text-green-300">{{ flash.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<!-- Reset Password Form -->
|
||||
{# Error Alert #}
|
||||
{% if flash.error is defined %}
|
||||
<div class="mb-6 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 dark:text-red-400 mr-2"></i>
|
||||
<span class="text-sm text-red-700 dark:text-red-300">{{ flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Reset Password Form #}
|
||||
<form method="POST" action="/reset-password" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
<!-- Hidden token field -->
|
||||
<input type="hidden" name="token" value="<?= htmlspecialchars($token ?? '') ?>">
|
||||
{{ csrf_field() }}
|
||||
|
||||
{# Hidden token field #}
|
||||
<input type="hidden" name="token" value="{{ token|default('') }}">
|
||||
|
||||
<!-- Password Field -->
|
||||
{# Password Field #}
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
New Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
@@ -45,21 +58,21 @@ ob_start();
|
||||
required
|
||||
minlength="8"
|
||||
autofocus
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Enter new password">
|
||||
<button
|
||||
type="button"
|
||||
onclick="togglePassword('password')"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none">
|
||||
<i class="fas fa-eye text-sm" id="toggleIcon-password"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Minimum 8 characters</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password Field -->
|
||||
{# Confirm Password Field #}
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
@@ -72,34 +85,34 @@ ob_start();
|
||||
name="password_confirm"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Re-enter new password">
|
||||
<button
|
||||
type="button"
|
||||
onclick="togglePassword('password_confirm')"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none">
|
||||
<i class="fas fa-eye text-sm" id="toggleIcon-password_confirm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Strength Indicator -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p class="text-xs text-blue-800 mb-2">
|
||||
{# Password Strength Indicator #}
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<p class="text-xs text-blue-800 dark:text-blue-300 mb-2">
|
||||
<i class="fas fa-shield-alt mr-1"></i>
|
||||
<strong>Password Requirements:</strong>
|
||||
</p>
|
||||
<ul class="text-xs text-blue-700 space-y-1 ml-5 list-disc">
|
||||
<ul class="text-xs text-blue-700 dark:text-blue-400 space-y-1 ml-5 list-disc">
|
||||
<li>At least 8 characters long</li>
|
||||
<li>Mix of uppercase and lowercase letters recommended</li>
|
||||
<li>Include numbers and special characters for extra security</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- CAPTCHA Widget -->
|
||||
<?php include __DIR__ . '/captcha-widget.php'; ?>
|
||||
{# CAPTCHA Widget #}
|
||||
{% include 'auth/_captcha-widget.twig' %}
|
||||
|
||||
<!-- Submit Button -->
|
||||
{# Submit Button #}
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors duration-200 flex items-center justify-center text-sm mt-6">
|
||||
@@ -108,17 +121,16 @@ ob_start();
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Back to Login Link -->
|
||||
<div class="text-center mt-6 pt-6 border-t border-gray-200">
|
||||
<a href="/login" class="inline-flex items-center text-sm text-gray-600 hover:text-gray-800">
|
||||
{# Back to Login Link #}
|
||||
<div class="text-center mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<a href="/login" class="inline-flex items-center text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
$scripts = <<<'SCRIPT'
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword(fieldId) {
|
||||
const passwordInput = document.getElementById(fieldId);
|
||||
@@ -146,6 +158,4 @@ $scripts = <<<'SCRIPT'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
SCRIPT;
|
||||
require __DIR__ . '/base-auth.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
@@ -1,60 +1,63 @@
|
||||
<?php
|
||||
$title = 'Verify Email';
|
||||
ob_start();
|
||||
?>
|
||||
{#
|
||||
# Verify Email Page
|
||||
#}
|
||||
{% extends 'auth/base-auth.twig' %}
|
||||
|
||||
<?php if ($verified ?? false): ?>
|
||||
<!-- Success State -->
|
||||
{% set title = 'Verify Email' %}
|
||||
|
||||
{% block content %}
|
||||
{% if verified|default(false) %}
|
||||
{# Success State #}
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||
<i class="fas fa-check-circle text-green-600 text-3xl"></i>
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full mb-4">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-400 text-3xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-2">Email Verified!</h1>
|
||||
<p class="text-gray-600 mb-6">Your email address has been successfully verified.</p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Email Verified!</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">Your email address has been successfully verified.</p>
|
||||
|
||||
<a href="/login" class="inline-flex items-center px-6 py-2.5 bg-primary hover:bg-primary-dark text-white text-sm rounded-lg transition-colors font-medium">
|
||||
<i class="fas fa-sign-in-alt mr-2"></i>
|
||||
Sign In to Your Account
|
||||
</a>
|
||||
</div>
|
||||
<?php elseif ($error ?? false): ?>
|
||||
<!-- Error State -->
|
||||
{% elseif error|default(false) %}
|
||||
{# Error State #}
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
|
||||
<i class="fas fa-times-circle text-red-600 text-3xl"></i>
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full mb-4">
|
||||
<i class="fas fa-times-circle text-red-600 dark:text-red-400 text-3xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-2">Verification Failed</h1>
|
||||
<p class="text-gray-600 mb-6"><?= htmlspecialchars($errorMessage ?? 'Invalid or expired verification link.') ?></p>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Verification Failed</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">{{ errorMessage|default('Invalid or expired verification link.') }}</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<a href="/login" class="block text-center px-6 py-2.5 bg-primary hover:bg-primary-dark text-white text-sm rounded-lg transition-colors font-medium">
|
||||
<i class="fas fa-sign-in-alt mr-2"></i>
|
||||
Go to Login
|
||||
</a>
|
||||
<a href="/resend-verification" class="block text-center px-6 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm rounded-lg transition-colors font-medium">
|
||||
<a href="/resend-verification" class="block text-center px-6 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 text-sm rounded-lg transition-colors font-medium">
|
||||
<i class="fas fa-redo mr-2"></i>
|
||||
Resend Verification Email
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- Pending State -->
|
||||
{% else %}
|
||||
{# Pending State #}
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4">
|
||||
<i class="fas fa-envelope text-blue-600 text-3xl"></i>
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full mb-4">
|
||||
<i class="fas fa-envelope text-blue-600 dark:text-blue-400 text-3xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 mb-2">Check Your Email</h1>
|
||||
<p class="text-gray-600 mb-6">
|
||||
We've sent a verification link to <strong><?= htmlspecialchars($email ?? 'your email') ?></strong>.
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Check Your Email</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
We've sent a verification link to <strong>{{ email|default('your email') }}</strong>.
|
||||
Please check your inbox and click the link to verify your account.
|
||||
</p>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 text-left">
|
||||
<p class="text-sm text-blue-800 mb-2">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6 text-left">
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300 mb-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
<strong>Didn't receive the email?</strong>
|
||||
</p>
|
||||
<ul class="text-xs text-blue-700 space-y-1 ml-5 list-disc">
|
||||
<ul class="text-xs text-blue-700 dark:text-blue-400 space-y-1 ml-5 list-disc">
|
||||
<li>Check your spam or junk folder</li>
|
||||
<li>Make sure you entered the correct email address</li>
|
||||
<li>Wait a few minutes for the email to arrive</li>
|
||||
@@ -66,14 +69,10 @@ ob_start();
|
||||
<i class="fas fa-redo mr-2"></i>
|
||||
Resend Verification Email
|
||||
</a>
|
||||
<a href="/login" class="block text-center text-sm text-gray-600 hover:text-gray-800">
|
||||
<a href="/login" class="block text-center text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/base-auth.php';
|
||||
?>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,347 +0,0 @@
|
||||
<?php
|
||||
$title = 'Dashboard';
|
||||
$pageTitle = 'Dashboard Overview';
|
||||
$pageDescription = 'Monitor your domains and expiration dates';
|
||||
$pageIcon = 'fas fa-chart-line';
|
||||
|
||||
// Get domain stats for dashboard (if not already set by base.php)
|
||||
if (!isset($domainStats)) {
|
||||
$domainStats = \App\Helpers\LayoutHelper::getDomainStats();
|
||||
}
|
||||
|
||||
// Prepare widget data
|
||||
$topRegistrars = array_slice($registrarCounts ?? [], 0, 8, true);
|
||||
$topTags = array_slice(array_filter($dashTags ?? [], fn($t) => ($t['usage_count'] ?? 0) > 0), 0, 8);
|
||||
$domainsWithoutGroup = ($totalDomainCount ?? 0) - ($domainsWithGroup ?? 0);
|
||||
$totalGroupCount = count($groups ?? []);
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<!-- System Status Bar (Admin) -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-5 py-3 mb-4">
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wide flex items-center">
|
||||
<i class="fas fa-server text-gray-400 mr-2"></i>
|
||||
System Status
|
||||
</span>
|
||||
<?php
|
||||
$statusColors = [
|
||||
'green' => 'text-green-600',
|
||||
'yellow' => 'text-yellow-600',
|
||||
'red' => 'text-red-600',
|
||||
'gray' => 'text-gray-600'
|
||||
];
|
||||
$statusDots = [
|
||||
'green' => 'bg-green-500',
|
||||
'yellow' => 'bg-yellow-500',
|
||||
'red' => 'bg-red-500',
|
||||
'gray' => 'bg-gray-400'
|
||||
];
|
||||
?>
|
||||
<div class="flex items-center gap-5 text-sm">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full <?= $statusDots[$systemStatus['database']['color']] ?>"></span>
|
||||
<span class="text-gray-500">Database</span>
|
||||
<span class="<?= $statusColors[$systemStatus['database']['color']] ?> font-medium"><?= ucfirst($systemStatus['database']['status']) ?></span>
|
||||
</span>
|
||||
<span class="text-gray-200">|</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full <?= $statusDots[$systemStatus['whois']['color']] ?>"></span>
|
||||
<span class="text-gray-500">TLD Registry</span>
|
||||
<span class="<?= $statusColors[$systemStatus['whois']['color']] ?> font-medium"><?= ucfirst($systemStatus['whois']['status']) ?></span>
|
||||
</span>
|
||||
<span class="text-gray-200">|</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full <?= $statusDots[$systemStatus['notifications']['color']] ?>"></span>
|
||||
<span class="text-gray-500">Notifications</span>
|
||||
<span class="<?= $statusColors[$systemStatus['notifications']['color']] ?> font-medium"><?= ucfirst($systemStatus['notifications']['status']) ?></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Domains</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $domainStats['total'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-globe text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Active</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $domainStats['active'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-check-circle text-green-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Expiring Soon</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $domainStats['expiring_soon'] ?? 0 ?></p>
|
||||
<p class="text-xs text-gray-400 mt-1">within <?= $domainStats['expiring_threshold'] ?? 30 ?> days</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-orange-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Inactive</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $domainStats['inactive'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-times-circle text-gray-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content: Recent Domains + Expiring Soon -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
<!-- Recent Domains -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-clock text-gray-400 mr-2 text-xs"></i>
|
||||
Recent Domains
|
||||
</h2>
|
||||
<a href="/domains" class="text-xs text-primary hover:text-primary-dark font-medium">
|
||||
View all <i class="fas fa-arrow-right ml-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<?php if (!empty($recentDomains)): ?>
|
||||
<div class="space-y-2">
|
||||
<?php foreach ($recentDomains as $domain): ?>
|
||||
<div class="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:border-gray-300 hover:shadow-sm transition-all duration-200">
|
||||
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div class="w-9 h-9 bg-gray-50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-globe text-gray-400 text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate"><?= htmlspecialchars($domain['domain_name']) ?></h3>
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-500 mt-0.5">
|
||||
<span class="flex items-center">
|
||||
<i class="far fa-calendar mr-1"></i>
|
||||
<?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Not set' ?>
|
||||
</span>
|
||||
<?php if ($domain['registrar']): ?>
|
||||
<span class="flex items-center truncate">
|
||||
<i class="fas fa-building mr-1"></i>
|
||||
<?= htmlspecialchars($domain['registrar']) ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-shrink-0">
|
||||
<span class="px-2 py-1 rounded text-xs font-medium <?= $domain['statusClass'] ?>">
|
||||
<?= $domain['statusText'] ?>
|
||||
</span>
|
||||
<a href="/domains/<?= $domain['id'] ?>" class="text-gray-400 hover:text-primary">
|
||||
<i class="fas fa-chevron-right text-sm"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-8">
|
||||
<i class="fas fa-globe text-gray-300 text-4xl mb-3"></i>
|
||||
<p class="text-sm text-gray-600">No domains added yet</p>
|
||||
<a href="/domains/create" class="mt-3 inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors duration-200">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Add Your First Domain
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expiring Soon -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-orange-500 mr-2 text-xs"></i>
|
||||
Expiring Soon
|
||||
</h2>
|
||||
<?php if (($expiringCount ?? 0) > 5): ?>
|
||||
<a href="/domains?status=expiring_soon" class="text-xs text-primary hover:text-primary-dark font-medium">
|
||||
View all <?= $expiringCount ?>
|
||||
<i class="fas fa-arrow-right ml-1"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!empty($expiringThisMonth)): ?>
|
||||
<div class="p-4 space-y-2">
|
||||
<?php foreach ($expiringThisMonth as $domain): ?>
|
||||
<?php
|
||||
$daysLeft = $domain['daysLeft'];
|
||||
$urgencyClass = $daysLeft <= 7 ? 'text-red-600' : ($daysLeft <= 30 ? 'text-orange-600' : 'text-yellow-600');
|
||||
?>
|
||||
<div class="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:border-gray-300 hover:shadow-sm transition-all duration-200">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate"><?= htmlspecialchars($domain['domain_name']) ?></p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
<?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?>
|
||||
<span class="<?= $urgencyClass ?> font-semibold ml-2">
|
||||
<?= $daysLeft ?> days
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="/domains/<?= $domain['id'] ?>" class="text-gray-400 hover:text-primary">
|
||||
<i class="fas fa-chevron-right text-sm"></i>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="p-6 text-center">
|
||||
<i class="fas fa-check-circle text-green-500 text-3xl mb-2"></i>
|
||||
<p class="text-sm text-gray-600">No domains expiring soon</p>
|
||||
<p class="text-xs text-gray-400 mt-1">within <?= $domainStats['expiring_threshold'] ?? 30 ?> days</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Insights Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Registrar Distribution -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-building text-gray-400 mr-2 text-xs"></i>
|
||||
Registrar Distribution
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500"><?= count($registrarCounts ?? []) ?> registrar<?= count($registrarCounts ?? []) != 1 ? 's' : '' ?></span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<?php if (!empty($topRegistrars)): ?>
|
||||
<div class="space-y-3">
|
||||
<?php foreach ($topRegistrars as $regName => $regCount): ?>
|
||||
<?php $regPct = ($totalDomainCount ?? 0) > 0 ? round(($regCount / $totalDomainCount) * 100) : 0; ?>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700 font-medium truncate mr-3"><?= htmlspecialchars($regName) ?></span>
|
||||
<span class="text-xs text-gray-500 whitespace-nowrap"><?= $regCount ?> (<?= $regPct ?>%)</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-1.5">
|
||||
<div class="bg-blue-500 rounded-full h-1.5" style="width: <?= max(2, $regPct) ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-building text-gray-300 text-2xl mb-2"></i>
|
||||
<p class="text-sm text-gray-500">No registrar data</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Usage -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-tags text-gray-400 mr-2 text-xs"></i>
|
||||
Tag Usage
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500"><?= count($dashTags ?? []) ?> tag<?= count($dashTags ?? []) != 1 ? 's' : '' ?></span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<?php if (!empty($topTags)): ?>
|
||||
<div class="space-y-3">
|
||||
<?php foreach ($topTags as $tt): ?>
|
||||
<?php $pct = ($totalDomainCount ?? 0) > 0 ? round(($tt['usage_count'] / $totalDomainCount) * 100) : 0; ?>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border <?= htmlspecialchars($tt['color'] ?? 'bg-gray-100 text-gray-700 border-gray-300') ?>">
|
||||
<i class="fas fa-tag mr-1" style="font-size: 8px;"></i>
|
||||
<?= htmlspecialchars($tt['name']) ?>
|
||||
</span>
|
||||
<span class="text-xs text-gray-500"><?= $tt['usage_count'] ?> domain<?= $tt['usage_count'] != 1 ? 's' : '' ?> (<?= $pct ?>%)</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-1.5">
|
||||
<div class="bg-primary rounded-full h-1.5" style="width: <?= max(2, $pct) ?>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-tags text-gray-300 text-2xl mb-2"></i>
|
||||
<p class="text-sm text-gray-500">No tags in use</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Coverage -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-bell text-gray-400 mr-2 text-xs"></i>
|
||||
Notification Coverage
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500"><?= $totalGroupCount ?> group<?= $totalGroupCount != 1 ? 's' : '' ?>, <?= $totalChannels ?? 0 ?> channel<?= ($totalChannels ?? 0) != 1 ? 's' : '' ?></span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<?php if (($totalDomainCount ?? 0) > 0): ?>
|
||||
<?php $coveragePct = round((($domainsWithGroup ?? 0) / $totalDomainCount) * 100); ?>
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<div class="relative w-28 h-28">
|
||||
<svg class="w-28 h-28 transform -rotate-90" viewBox="0 0 36 36">
|
||||
<path class="text-gray-200" stroke="currentColor" stroke-width="3" fill="none" d="M18 2.0845a15.9155 15.9155 0 0 1 0 31.831 15.9155 15.9155 0 0 1 0-31.831"/>
|
||||
<path class="text-primary" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="<?= $coveragePct ?>, 100" stroke-linecap="round" d="M18 2.0845a15.9155 15.9155 0 0 1 0 31.831 15.9155 15.9155 0 0 1 0-31.831"/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-xl font-bold text-gray-900"><?= $coveragePct ?>%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 text-center">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<p class="text-lg font-bold text-green-700"><?= $domainsWithGroup ?? 0 ?></p>
|
||||
<p class="text-xs text-green-600">With Notifications</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||
<p class="text-lg font-bold text-gray-700"><?= $domainsWithoutGroup ?></p>
|
||||
<p class="text-xs text-gray-500">Without Notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-bell-slash text-gray-300 text-2xl mb-2"></i>
|
||||
<p class="text-sm text-gray-500">No domains to monitor</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
378
app/Views/dashboard/index.twig
Normal file
378
app/Views/dashboard/index.twig
Normal file
@@ -0,0 +1,378 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Dashboard' %}
|
||||
{% set pageTitle = 'Dashboard Overview' %}
|
||||
{% set pageDescription = 'Monitor your domains and expiration dates' %}
|
||||
{% set pageIcon = 'fas fa-chart-line' %}
|
||||
|
||||
{% set topRegistrars = registrarCounts|default({})|slice(0, 8) %}
|
||||
{% set topTags = dashTags|default([])|filter(t => t.usage_count|default(0) > 0)|slice(0, 8) %}
|
||||
{% set domainsWithoutGroup = totalDomainCount|default(0) - domainsWithGroup|default(0) %}
|
||||
{% set totalGroupCount = groups|default([])|length %}
|
||||
|
||||
{% block content %}
|
||||
{# Welcome Banner #}
|
||||
<div class="mb-4 bg-gradient-to-r from-primary to-blue-600 rounded-lg p-4 text-white relative overflow-hidden">
|
||||
<div class="absolute right-0 top-0 w-48 h-48 opacity-10">
|
||||
<i class="fas fa-globe text-[150px] -rotate-12 -translate-y-6 translate-x-6"></i>
|
||||
</div>
|
||||
<div class="relative z-10">
|
||||
<h2 class="text-lg font-bold">Welcome back, {{ auth.fullName|default(auth.username)|default('User') }}!</h2>
|
||||
<p class="text-blue-100 mt-1 text-xs">
|
||||
{% if domainStats.expiring_soon|default(0) > 0 %}
|
||||
You have <span class="font-semibold text-white">{{ domainStats.expiring_soon }}</span> domain{{ domainStats.expiring_soon > 1 ? 's' : '' }} expiring within {{ domainStats.expiring_threshold|default(30) }} days.
|
||||
{% else %}
|
||||
All your domains are in good standing. No urgent actions needed.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<a href="/domains/create" class="inline-flex items-center px-3 py-1.5 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-lg text-xs font-medium transition-colors">
|
||||
<i class="fas fa-plus mr-1.5 text-xs"></i>Add Domain
|
||||
</a>
|
||||
<a href="/debug/whois" class="inline-flex items-center px-3 py-1.5 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg text-xs font-medium transition-colors">
|
||||
<i class="fas fa-search mr-1.5 text-xs"></i>WHOIS Lookup
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if auth.isAdmin %}
|
||||
{# System Status Bar (Admin) #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 px-5 py-3 mb-4">
|
||||
<div class="flex flex-wrap items-center gap-4 sm:gap-6">
|
||||
<span class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide flex items-center">
|
||||
<i class="fas fa-server text-gray-400 dark:text-slate-500 mr-2"></i>
|
||||
System Status
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-3 sm:gap-5 text-sm">
|
||||
{% set statusColors = {'green': 'text-green-600 dark:text-green-400', 'yellow': 'text-yellow-600 dark:text-yellow-400', 'red': 'text-red-600 dark:text-red-400', 'gray': 'text-gray-600 dark:text-slate-400'} %}
|
||||
{% set statusDots = {'green': 'bg-green-500', 'yellow': 'bg-yellow-500', 'red': 'bg-red-500', 'gray': 'bg-gray-400'} %}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full {{ statusDots[systemStatus.database.color] }}"></span>
|
||||
<span class="text-gray-500 dark:text-slate-400">Database</span>
|
||||
<span class="{{ statusColors[systemStatus.database.color] }} font-medium">{{ systemStatus.database.status|capitalize }}</span>
|
||||
</span>
|
||||
<span class="text-gray-200 dark:text-slate-700 hidden sm:inline">|</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full {{ statusDots[systemStatus.whois.color] }}"></span>
|
||||
<span class="text-gray-500 dark:text-slate-400">TLD Registry</span>
|
||||
<span class="{{ statusColors[systemStatus.whois.color] }} font-medium">{{ systemStatus.whois.status|capitalize }}</span>
|
||||
</span>
|
||||
<span class="text-gray-200 dark:text-slate-700 hidden sm:inline">|</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full {{ statusDots[systemStatus.notifications.color] }}"></span>
|
||||
<span class="text-gray-500 dark:text-slate-400">Notifications</span>
|
||||
<span class="{{ statusColors[systemStatus.notifications.color] }} font-medium">{{ systemStatus.notifications.status|capitalize }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Statistics Cards #}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
{# Total Domains Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4 hover:shadow-md dark:hover:shadow-slate-700/50 transition-all duration-200">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Total Domains</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white mt-1.5">{{ domainStats.total|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-9 h-9 bg-blue-100 dark:bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-globe text-blue-600 dark:text-blue-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2.5 pt-2.5 border-t border-gray-100 dark:border-slate-700">
|
||||
<a href="/domains" class="text-xs text-primary dark:text-blue-400 hover:underline font-medium">
|
||||
View all <i class="fas fa-arrow-right ml-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active Domains Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4 hover:shadow-md dark:hover:shadow-slate-700/50 transition-all duration-200">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Active</p>
|
||||
<p class="text-2xl font-bold text-green-600 dark:text-green-400 mt-1.5">{{ domainStats.active|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-9 h-9 bg-green-100 dark:bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2.5 pt-2.5 border-t border-gray-100 dark:border-slate-700">
|
||||
{% if domainStats.total|default(0) > 0 %}
|
||||
{% set activePercent = ((domainStats.active|default(0) / domainStats.total) * 100)|round %}
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400">{{ activePercent }}% of total</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400">No domains yet</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Expiring Soon Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4 hover:shadow-md dark:hover:shadow-slate-700/50 transition-all duration-200 {{ domainStats.expiring_soon|default(0) > 0 ? 'ring-2 ring-orange-500/50' : '' }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Expiring Soon</p>
|
||||
<p class="text-2xl font-bold {{ domainStats.expiring_soon|default(0) > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-gray-900 dark:text-white' }} mt-1.5">{{ domainStats.expiring_soon|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-9 h-9 {{ domainStats.expiring_soon|default(0) > 0 ? 'bg-orange-100 dark:bg-orange-500/20' : 'bg-gray-100 dark:bg-slate-700' }} rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-clock {{ domainStats.expiring_soon|default(0) > 0 ? 'text-orange-600 dark:text-orange-400' : 'text-gray-400 dark:text-slate-500' }} text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2.5 pt-2.5 border-t border-gray-100 dark:border-slate-700">
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400">within {{ domainStats.expiring_threshold|default(30) }} days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Inactive Domains Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4 hover:shadow-md dark:hover:shadow-slate-700/50 transition-all duration-200">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Inactive</p>
|
||||
<p class="text-2xl font-bold text-gray-400 dark:text-slate-500 mt-1.5">{{ domainStats.inactive|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-9 h-9 bg-gray-100 dark:bg-slate-700 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-pause-circle text-gray-400 dark:text-slate-500 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2.5 pt-2.5 border-t border-gray-100 dark:border-slate-700">
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400">monitoring paused</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Main Content: Recent Domains + Expiring Soon #}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
{# Recent Domains #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-clock text-gray-400 dark:text-slate-500 mr-2 text-xs"></i>
|
||||
Recent Domains
|
||||
</h2>
|
||||
<a href="/domains" class="text-xs text-primary hover:text-primary-dark font-medium">
|
||||
View all <i class="fas fa-arrow-right ml-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{% if recentDomains is not empty %}
|
||||
<div class="space-y-2">
|
||||
{% for domain in recentDomains %}
|
||||
<div class="flex items-center justify-between p-3 border border-gray-100 dark:border-slate-700 rounded-lg hover:border-gray-300 dark:hover:border-slate-600 hover:shadow-sm transition-all duration-200">
|
||||
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div class="w-9 h-9 bg-gray-50 dark:bg-slate-700 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-slate-500 text-sm"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ domain.domain_name }}</h3>
|
||||
<div class="flex items-center space-x-3 text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
<span class="flex items-center">
|
||||
<i class="far fa-calendar mr-1"></i>
|
||||
{{ domain.expiration_date ? domain.expiration_date|date('M d, Y') : 'Not set' }}
|
||||
</span>
|
||||
{% if domain.registrar %}
|
||||
<span class="flex items-center truncate">
|
||||
<i class="fas fa-building mr-1"></i>
|
||||
{{ domain.registrar }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-shrink-0">
|
||||
<span class="px-2 py-1 rounded text-xs font-medium {{ domain.statusClass }}">
|
||||
{{ domain.statusText }}
|
||||
</span>
|
||||
<a href="/domains/{{ domain.id }}" class="text-gray-400 dark:text-slate-500 hover:text-primary">
|
||||
<i class="fas fa-chevron-right text-sm"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<i class="fas fa-globe text-gray-300 dark:text-slate-600 text-4xl mb-3"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">No domains added yet</p>
|
||||
<a href="/domains/create" class="mt-3 inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors duration-200">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Add Your First Domain
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Expiring Soon #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-orange-500 mr-2 text-xs"></i>
|
||||
Expiring Soon
|
||||
</h2>
|
||||
{% if expiringCount|default(0) > 5 %}
|
||||
<a href="/domains?status=expiring_soon" class="text-xs text-primary hover:text-primary-dark font-medium">
|
||||
View all {{ expiringCount }}
|
||||
<i class="fas fa-arrow-right ml-1"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if expiringThisMonth is not empty %}
|
||||
<div class="p-4 space-y-2">
|
||||
{% for domain in expiringThisMonth %}
|
||||
{% set daysLeft = domain.daysLeft %}
|
||||
{% if daysLeft <= 7 %}
|
||||
{% set urgencyClass = 'text-red-600 dark:text-red-400' %}
|
||||
{% elseif daysLeft <= 30 %}
|
||||
{% set urgencyClass = 'text-orange-600 dark:text-orange-400' %}
|
||||
{% else %}
|
||||
{% set urgencyClass = 'text-yellow-600 dark:text-yellow-400' %}
|
||||
{% endif %}
|
||||
<div class="flex items-center justify-between p-3 border border-gray-100 dark:border-slate-700 rounded-lg hover:border-gray-300 dark:hover:border-slate-600 hover:shadow-sm transition-all duration-200">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ domain.domain_name }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
{{ domain.expiration_date ? domain.expiration_date|date('M d, Y') : 'Unknown' }}
|
||||
<span class="{{ urgencyClass }} font-semibold ml-2">
|
||||
{{ daysLeft }} days
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<a href="/domains/{{ domain.id }}" class="text-gray-400 dark:text-slate-500 hover:text-primary">
|
||||
<i class="fas fa-chevron-right text-sm"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 text-center">
|
||||
<i class="fas fa-check-circle text-green-500 text-3xl mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">No domains expiring soon</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mt-1">within {{ domainStats.expiring_threshold|default(30) }} days</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Insights Row #}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{# Registrar Distribution #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-building text-gray-400 dark:text-slate-500 mr-2 text-xs"></i>
|
||||
Registrar Distribution
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400">{{ registrarCounts|default({})|length }} registrar{{ registrarCounts|default({})|length != 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
{% if topRegistrars is not empty %}
|
||||
<div class="space-y-3">
|
||||
{% for regName, regCount in topRegistrars %}
|
||||
{% set regPct = totalDomainCount|default(0) > 0 ? ((regCount / totalDomainCount) * 100)|round : 0 %}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700 dark:text-slate-300 font-medium truncate mr-3">{{ regName }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">{{ regCount }} ({{ regPct }}%)</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 dark:bg-slate-700 rounded-full h-1.5">
|
||||
<div class="bg-blue-500 rounded-full h-1.5" style="width: {{ max(2, regPct) }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-building text-gray-300 dark:text-slate-600 text-2xl mb-2"></i>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">No registrar data</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Tag Usage #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-tags text-gray-400 dark:text-slate-500 mr-2 text-xs"></i>
|
||||
Tag Usage
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400">{{ dashTags|default([])|length }} tag{{ dashTags|default([])|length != 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
{% if topTags is not empty %}
|
||||
<div class="space-y-3">
|
||||
{% for tt in topTags %}
|
||||
{% set pct = totalDomainCount|default(0) > 0 ? ((tt.usage_count / totalDomainCount) * 100)|round : 0 %}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border {{ tt.color|default('bg-gray-100 text-gray-700 border-gray-300') }}">
|
||||
<i class="fas fa-tag mr-1" style="font-size: 8px;"></i>
|
||||
{{ tt.name }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400">{{ tt.usage_count }} domain{{ tt.usage_count != 1 ? 's' : '' }} ({{ pct }}%)</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 dark:bg-slate-700 rounded-full h-1.5">
|
||||
<div class="bg-primary rounded-full h-1.5" style="width: {{ max(2, pct) }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-tags text-gray-300 dark:text-slate-600 text-2xl mb-2"></i>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">No tags in use</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Notification Coverage #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bell text-gray-400 dark:text-slate-500 mr-2 text-xs"></i>
|
||||
Notification Coverage
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-slate-400">{{ totalGroupCount }} group{{ totalGroupCount != 1 ? 's' : '' }}, {{ totalChannels|default(0) }} channel{{ totalChannels|default(0) != 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
{% if totalDomainCount|default(0) > 0 %}
|
||||
{% set coveragePct = ((domainsWithGroup|default(0) / totalDomainCount) * 100)|round %}
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<div class="relative w-28 h-28">
|
||||
<svg class="w-28 h-28 transform -rotate-90" viewBox="0 0 36 36">
|
||||
<path class="text-gray-200 dark:text-slate-700" stroke="currentColor" stroke-width="3" fill="none" d="M18 2.0845a15.9155 15.9155 0 0 1 0 31.831 15.9155 15.9155 0 0 1 0-31.831"/>
|
||||
<path class="text-primary" stroke="currentColor" stroke-width="3" fill="none" stroke-dasharray="{{ coveragePct }}, 100" stroke-linecap="round" d="M18 2.0845a15.9155 15.9155 0 0 1 0 31.831 15.9155 15.9155 0 0 1 0-31.831"/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ coveragePct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 text-center">
|
||||
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-3">
|
||||
<p class="text-lg font-bold text-green-700 dark:text-green-400">{{ domainsWithGroup|default(0) }}</p>
|
||||
<p class="text-xs text-green-600 dark:text-green-500">With Notifications</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-lg p-3">
|
||||
<p class="text-lg font-bold text-gray-700 dark:text-slate-300">{{ domainsWithoutGroup }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">Without Notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-bell-slash text-gray-300 dark:text-slate-600 text-2xl mb-2"></i>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">No domains to monitor</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,28 +1,29 @@
|
||||
<?php
|
||||
$title = 'WHOIS Debug Tool';
|
||||
$pageTitle = 'WHOIS Debug Tool';
|
||||
$pageDescription = 'Test and debug WHOIS data extraction';
|
||||
$pageIcon = 'fas fa-search';
|
||||
ob_start();
|
||||
?>
|
||||
{% extends "layout/base.twig" %}
|
||||
|
||||
<?php if (empty($domain)): ?>
|
||||
{% set title = 'WHOIS Debug Tool' %}
|
||||
{% set pageTitle = 'WHOIS Debug Tool' %}
|
||||
{% set pageDescription = 'Test and debug WHOIS data extraction' %}
|
||||
{% set pageIcon = 'fas fa-search' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if domain is empty %}
|
||||
<!-- Search Form -->
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
||||
<form method="GET" action="/debug/whois" class="space-y-4">
|
||||
<div>
|
||||
<label for="domain" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="domain" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Domain Name
|
||||
</label>
|
||||
<input type="text"
|
||||
id="domain"
|
||||
name="domain"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
placeholder="Enter domain (e.g., google.com)"
|
||||
required
|
||||
autofocus>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Enter a domain name without http:// or www.
|
||||
</p>
|
||||
</div>
|
||||
@@ -36,14 +37,14 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-500 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">What is this tool?</h3>
|
||||
<p class="text-xs text-gray-600 leading-relaxed">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">What is this tool?</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 leading-relaxed">
|
||||
This debug tool shows you the raw WHOIS data for any domain and how our system parses it.
|
||||
Use it to troubleshoot issues with domain information extraction.
|
||||
</p>
|
||||
@@ -52,10 +53,10 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
{% else %}
|
||||
<!-- Back Button & Copy Report -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<a href="/debug/whois" class="inline-flex items-center px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<a href="/debug/whois" class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Check Another Domain
|
||||
</a>
|
||||
@@ -66,19 +67,19 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<!-- Domain Info Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 mb-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Domain</p>
|
||||
<p class="text-sm font-semibold text-gray-900 mt-1"><?= htmlspecialchars($domain) ?></p>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Domain</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">{{ domain }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">WHOIS Server</p>
|
||||
<p class="text-sm font-semibold text-gray-900 mt-1"><?= htmlspecialchars($server) ?></p>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">WHOIS Server</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">{{ server }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">TLD</p>
|
||||
<p class="text-sm font-semibold text-gray-900 mt-1"><?= htmlspecialchars($tld) ?></p>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">TLD</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">{{ tld }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,53 +87,53 @@ ob_start();
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Parsed Data -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 bg-green-50">
|
||||
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-check-circle text-green-600 mr-2 text-sm"></i>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 dark:border-slate-700 bg-green-50 dark:bg-green-500/10">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mr-2 text-sm"></i>
|
||||
Extracted Data (What We Save)
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-xs font-medium text-gray-600">Domain</span>
|
||||
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['domain'] ?? 'N/A') ?></span>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100 dark:border-slate-700">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-slate-400">Domain</span>
|
||||
<span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.domain ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-xs font-medium text-gray-600">Registrar</span>
|
||||
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['registrar'] ?? 'N/A') ?></span>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100 dark:border-slate-700">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-slate-400">Registrar</span>
|
||||
<span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.registrar ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-xs font-medium text-gray-600">Expiration Date</span>
|
||||
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['expiration_date'] ?? 'N/A') ?></span>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100 dark:border-slate-700">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-slate-400">Expiration Date</span>
|
||||
<span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.expiration_date ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-xs font-medium text-gray-600">Creation Date</span>
|
||||
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['creation_date'] ?? 'N/A') ?></span>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100 dark:border-slate-700">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-slate-400">Creation Date</span>
|
||||
<span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.creation_date ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-xs font-medium text-gray-600">Updated Date</span>
|
||||
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['updated_date'] ?? 'N/A') ?></span>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100 dark:border-slate-700">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-slate-400">Updated Date</span>
|
||||
<span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.updated_date ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-xs font-medium text-gray-600">Registrar URL</span>
|
||||
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['registrar_url'] ?? 'N/A') ?></span>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100 dark:border-slate-700">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-slate-400">Registrar URL</span>
|
||||
<span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.registrar_url ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100">
|
||||
<span class="text-xs font-medium text-gray-600">Abuse Email</span>
|
||||
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['abuse_email'] ?? 'N/A') ?></span>
|
||||
<div class="flex justify-between py-2 border-b border-gray-100 dark:border-slate-700">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-slate-400">Abuse Email</span>
|
||||
<span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.abuse_email ?? 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<span class="text-xs font-medium text-gray-600 block mb-2">Nameservers</span>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-slate-400 block mb-2">Nameservers</span>
|
||||
<div class="space-y-1">
|
||||
<?php if (!empty($info['nameservers'])): ?>
|
||||
<?php foreach ($info['nameservers'] as $ns): ?>
|
||||
<div class="text-xs text-gray-900 font-mono bg-gray-50 px-2 py-1 rounded"><?= htmlspecialchars($ns) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<span class="text-xs text-gray-400">N/A</span>
|
||||
<?php endif; ?>
|
||||
{% if info.nameservers is not empty %}
|
||||
{% for ns in info.nameservers %}
|
||||
<div class="text-xs text-gray-900 dark:text-white font-mono bg-gray-50 dark:bg-slate-700 px-2 py-1 rounded">{{ ns }}</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-400 dark:text-slate-500">N/A</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,30 +141,30 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<!-- Key-Value Pairs -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 bg-blue-50">
|
||||
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-table text-blue-600 mr-2 text-sm"></i>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 dark:border-slate-700 bg-blue-50 dark:bg-blue-500/10">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-table text-blue-600 dark:text-blue-400 mr-2 text-sm"></i>
|
||||
All Key-Value Pairs
|
||||
</h2>
|
||||
</div>
|
||||
<div class="overflow-y-auto" style="max-height: 500px;">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-600 uppercase">Key</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-600 uppercase">Value</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-600 dark:text-slate-400 uppercase">Key</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-600 dark:text-slate-400 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
<?php foreach ($parsedData as $item): ?>
|
||||
<?php if (!empty($item['value'])): ?>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2 text-xs font-medium text-gray-700"><?= htmlspecialchars($item['key']) ?></td>
|
||||
<td class="px-4 py-2 text-xs text-gray-900 font-mono"><?= htmlspecialchars($item['value']) ?></td>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{% for item in parsedData %}
|
||||
{% if item.value is not empty %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="px-4 py-2 text-xs font-medium text-gray-700 dark:text-slate-300">{{ item.key }}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-900 dark:text-white font-mono">{{ item.value }}</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -171,27 +172,27 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<!-- Raw Response -->
|
||||
<div class="mt-4 bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<h2 class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-file-code text-gray-600 mr-2 text-sm"></i>
|
||||
<div class="mt-4 bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-file-code text-gray-600 dark:text-slate-400 mr-2 text-sm"></i>
|
||||
Raw WHOIS Response
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<pre class="text-xs font-mono bg-gray-50 p-4 rounded border border-gray-200 overflow-x-auto"><?= htmlspecialchars($response) ?></pre>
|
||||
<pre class="text-xs font-mono bg-gray-50 dark:bg-slate-900 p-4 rounded border border-gray-200 dark:border-slate-700 overflow-x-auto text-gray-900 dark:text-slate-300">{{ response }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden data for JS -->
|
||||
<script id="debug-data" type="application/json">
|
||||
{
|
||||
"domain": <?= json_encode($domain) ?>,
|
||||
"tld": <?= json_encode($tld) ?>,
|
||||
"server": <?= json_encode($server) ?>,
|
||||
"extractedData": <?= json_encode($info) ?>,
|
||||
"rawResponse": <?= json_encode($response) ?>,
|
||||
"parsedKeyValuePairs": <?= json_encode($parsedData) ?>
|
||||
"domain": {{ domain|json_encode|raw }},
|
||||
"tld": {{ tld|json_encode|raw }},
|
||||
"server": {{ server|json_encode|raw }},
|
||||
"extractedData": {{ info|json_encode|raw }},
|
||||
"rawResponse": {{ response|json_encode|raw }},
|
||||
"parsedKeyValuePairs": {{ parsedData|json_encode|raw }}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -231,33 +232,26 @@ ob_start();
|
||||
report += data.rawResponse;
|
||||
report += `\n\n=== END OF REPORT ===`;
|
||||
|
||||
// Copy to clipboard with fallback
|
||||
copyToClipboard(report, button);
|
||||
}
|
||||
|
||||
// Robust clipboard copy function with fallback
|
||||
function copyToClipboard(text, button) {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopySuccess(button);
|
||||
}).catch(err => {
|
||||
console.error('Modern clipboard API failed:', err);
|
||||
// Fallback to legacy method
|
||||
fallbackCopyTextToClipboard(text, button);
|
||||
});
|
||||
} else {
|
||||
// Use fallback for non-HTTPS or older browsers
|
||||
fallbackCopyTextToClipboard(text, button);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopyTextToClipboard(text, button) {
|
||||
// Create a temporary textarea
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
|
||||
// Make it invisible but accessible
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.top = '0';
|
||||
textArea.style.left = '0';
|
||||
@@ -308,10 +302,6 @@ ob_start();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,60 +1,61 @@
|
||||
<?php
|
||||
$title = 'Bulk Add Domains';
|
||||
$pageTitle = 'Bulk Add Domains';
|
||||
$pageDescription = 'Add multiple domains at once';
|
||||
$pageIcon = 'fas fa-layer-group';
|
||||
ob_start();
|
||||
?>
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Bulk Add Domains' %}
|
||||
{% set pageTitle = 'Bulk Add Domains' %}
|
||||
{% set pageDescription = 'Add multiple domains at once' %}
|
||||
{% set pageIcon = 'fas fa-layer-group' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Main Container -->
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 bg-gray-50">
|
||||
<button onclick="switchTab('paste')" id="tab-paste" class="px-6 py-3 text-sm font-medium border-b-2 border-primary text-primary bg-white transition-colors">
|
||||
<div class="flex border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<button onclick="switchTab('paste')" id="tab-paste" class="px-6 py-3 text-sm font-medium border-b-2 border-primary text-primary bg-white dark:bg-slate-800 transition-colors">
|
||||
<i class="fas fa-keyboard mr-2"></i>Paste Domains
|
||||
</button>
|
||||
<button onclick="switchTab('import')" id="tab-import" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition-colors">
|
||||
<button onclick="switchTab('import')" id="tab-import" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 hover:border-gray-300 dark:hover:border-slate-600 transition-colors">
|
||||
<i class="fas fa-file-upload mr-2"></i>Import from File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Paste Domains (existing) -->
|
||||
<!-- Tab 1: Paste Domains -->
|
||||
<div id="panel-paste" class="p-6">
|
||||
<form method="POST" action="/domains/bulk-add" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<!-- Domains Textarea -->
|
||||
<div>
|
||||
<label for="domains" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="domains" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Domain Names *
|
||||
</label>
|
||||
<textarea
|
||||
id="domains"
|
||||
name="domains"
|
||||
rows="10"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono"
|
||||
placeholder="example.com google.com github.com ..."
|
||||
required
|
||||
autofocus></textarea>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Enter one domain per line. Domains without http:// or www.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label for="tags-input" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="tags-input" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Tags
|
||||
<span class="text-gray-400 font-normal">(Optional)</span>
|
||||
<span class="text-gray-400 dark:text-slate-500 font-normal">(Optional)</span>
|
||||
</label>
|
||||
|
||||
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50"></div>
|
||||
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 dark:border-slate-600 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50 dark:bg-slate-900"></div>
|
||||
|
||||
<div class="relative">
|
||||
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-sm"></i>
|
||||
<input type="text"
|
||||
id="tags-input"
|
||||
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Type any tag and press Enter or comma..."
|
||||
onkeydown="handleTagInput(event)">
|
||||
<button type="button" onclick="addTagFromInput()" class="absolute right-2 top-1/2 transform -translate-y-1/2 px-3 py-1 bg-primary text-white text-xs rounded hover:bg-primary-dark">
|
||||
@@ -64,39 +65,39 @@ ob_start();
|
||||
|
||||
<input type="hidden" id="tags" name="tags" value="">
|
||||
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
All imported domains will be tagged with these tags.
|
||||
</p>
|
||||
|
||||
<div class="mt-2">
|
||||
<p class="text-xs text-gray-600 mb-1.5">Available Tags:</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">Available Tags:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80 transition-colors">
|
||||
{% for tag in availableTags %}
|
||||
<button type="button" onclick="addTag('{{ tag.name }}')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Group -->
|
||||
<div>
|
||||
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Notification Group (Optional)
|
||||
</label>
|
||||
<select id="notification_group_id"
|
||||
name="notification_group_id"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
<option value="">-- No Group (No notifications) --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Assign all domains to this notification group
|
||||
</p>
|
||||
</div>
|
||||
@@ -109,7 +110,7 @@ ob_start();
|
||||
Add All Domains
|
||||
</button>
|
||||
<a href="/domains"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
@@ -120,30 +121,30 @@ ob_start();
|
||||
<!-- Tab 2: Import from File -->
|
||||
<div id="panel-import" class="hidden p-6">
|
||||
<form method="POST" action="/domains/import" enctype="multipart/form-data" class="space-y-5" id="domainImportForm">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
|
||||
<!-- Drag & Drop Zone -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Select File *
|
||||
</label>
|
||||
<div id="domainDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
|
||||
<div id="domainDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-8 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="domainFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="domainDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-3"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1.5">or</p>
|
||||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 dark:text-slate-500 mb-3"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1.5">or</p>
|
||||
<span class="inline-flex items-center px-4 py-2 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-3 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
<p class="mt-3 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="domainDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700" id="domainFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="domainFileSize"></p>
|
||||
<button type="button" id="domainFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="domainFileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="domainFileSize"></p>
|
||||
<button type="button" id="domainFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
@@ -151,31 +152,31 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<!-- Expected Format Info -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p class="text-sm font-medium text-gray-900 mb-2"><i class="fas fa-info-circle text-blue-500 mr-1.5"></i>Expected File Format</p>
|
||||
<p class="text-xs text-gray-600 mb-2">CSV columns or JSON fields:</p>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white mb-2"><i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mr-1.5"></i>Expected File Format</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mb-2">CSV columns or JSON fields:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-blue-200 font-semibold text-blue-800">domain_name *</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">tags</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notes</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notification_group</code>
|
||||
<code class="px-2 py-0.5 bg-white dark:bg-slate-800 rounded text-xs border border-blue-200 dark:border-blue-800 font-semibold text-blue-800 dark:text-blue-400">domain_name *</code>
|
||||
<code class="px-2 py-0.5 bg-white dark:bg-slate-800 rounded text-xs border border-gray-200 dark:border-slate-700 text-gray-600 dark:text-slate-400">tags</code>
|
||||
<code class="px-2 py-0.5 bg-white dark:bg-slate-800 rounded text-xs border border-gray-200 dark:border-slate-700 text-gray-600 dark:text-slate-400">notes</code>
|
||||
<code class="px-2 py-0.5 bg-white dark:bg-slate-800 rounded text-xs border border-gray-200 dark:border-slate-700 text-gray-600 dark:text-slate-400">notification_group</code>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">Only <code class="bg-white px-1 rounded">domain_name</code> is required. Tags should be comma-separated. Notification group is matched by name.</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2">Only <code class="bg-white dark:bg-slate-800 px-1 rounded">domain_name</code> is required. Tags should be comma-separated. Notification group is matched by name.</p>
|
||||
</div>
|
||||
|
||||
<!-- Fallback Notification Group -->
|
||||
<div>
|
||||
<label for="import_notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="import_notification_group_id" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Default Notification Group
|
||||
<span class="text-gray-400 font-normal">(for domains without a group in the file)</span>
|
||||
<span class="text-gray-400 dark:text-slate-500 font-normal">(for domains without a group in the file)</span>
|
||||
</label>
|
||||
<select id="import_notification_group_id"
|
||||
name="notification_group_id"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
<option value="">-- No Group (No notifications) --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +188,7 @@ ob_start();
|
||||
Import Domains
|
||||
</button>
|
||||
<a href="/domains"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
@@ -198,7 +199,7 @@ ob_start();
|
||||
|
||||
<!-- Info Cards -->
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
@@ -206,8 +207,8 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3>
|
||||
<p class="text-xs text-gray-600 leading-relaxed">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">How It Works</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 leading-relaxed">
|
||||
Paste domain names or upload a CSV/JSON file. The system will fetch WHOIS information
|
||||
for each domain automatically. This may take a few moments depending on how many domains you're adding.
|
||||
</p>
|
||||
@@ -215,7 +216,7 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div class="bg-orange-50 dark:bg-orange-500/10 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
@@ -223,8 +224,8 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">Important Notes</h3>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">Important Notes</h3>
|
||||
<ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
|
||||
<li class="flex items-start">
|
||||
<i class="fas fa-circle text-orange-500 mt-1 mr-2" style="font-size: 6px;"></i>
|
||||
<span>Duplicate domains will be skipped</span>
|
||||
@@ -244,8 +245,10 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tab) {
|
||||
document.getElementById('panel-paste').classList.toggle('hidden', tab !== 'paste');
|
||||
document.getElementById('panel-import').classList.toggle('hidden', tab !== 'import');
|
||||
@@ -254,18 +257,17 @@ function switchTab(tab) {
|
||||
const importTab = document.getElementById('tab-import');
|
||||
|
||||
[pasteTab, importTab].forEach(btn => {
|
||||
btn.classList.remove('border-primary', 'text-primary', 'bg-white', 'border-transparent', 'text-gray-500');
|
||||
btn.classList.remove('border-primary', 'text-primary', 'bg-white', 'dark:bg-slate-800', 'border-transparent', 'text-gray-500', 'dark:text-slate-400');
|
||||
});
|
||||
const active = tab === 'paste' ? pasteTab : importTab;
|
||||
const inactive = tab === 'paste' ? importTab : pasteTab;
|
||||
active.classList.add('border-primary', 'text-primary', 'bg-white');
|
||||
inactive.classList.add('border-transparent', 'text-gray-500');
|
||||
active.classList.add('border-primary', 'text-primary', 'bg-white', 'dark:bg-slate-800');
|
||||
inactive.classList.add('border-transparent', 'text-gray-500', 'dark:text-slate-400');
|
||||
}
|
||||
|
||||
let tags = [];
|
||||
|
||||
// Available tags with their colors from the database
|
||||
const availableTags = <?= json_encode($availableTags) ?>;
|
||||
const availableTags = {{ availableTags|json_encode|raw }};
|
||||
const tagColors = {};
|
||||
availableTags.forEach(tag => {
|
||||
tagColors[tag.name] = tag.color;
|
||||
@@ -274,12 +276,10 @@ availableTags.forEach(tag => {
|
||||
function addTag(tagName) {
|
||||
tagName = tagName.trim().toLowerCase();
|
||||
|
||||
// Validate tag (alphanumeric and hyphens only)
|
||||
if (!/^[a-z0-9-]+$/.test(tagName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tag already exists
|
||||
if (tags.includes(tagName)) {
|
||||
return;
|
||||
}
|
||||
@@ -288,7 +288,6 @@ function addTag(tagName) {
|
||||
updateTagsDisplay();
|
||||
updateHiddenInput();
|
||||
|
||||
// Clear input
|
||||
document.getElementById('tags-input').value = '';
|
||||
}
|
||||
|
||||
@@ -303,7 +302,7 @@ function updateTagsDisplay() {
|
||||
display.innerHTML = '';
|
||||
|
||||
if (tags.length === 0) {
|
||||
display.innerHTML = '<span class="text-xs text-gray-400 italic">No tags added yet</span>';
|
||||
display.innerHTML = '<span class="text-xs text-gray-400 dark:text-slate-500 italic">No tags added yet</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -338,17 +337,14 @@ function addTagFromInput() {
|
||||
const value = input.value.trim();
|
||||
|
||||
if (value) {
|
||||
// Handle multiple tags separated by commas
|
||||
const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t);
|
||||
newTags.forEach(tag => addTag(tag));
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize display
|
||||
updateTagsDisplay();
|
||||
|
||||
// --- Domain Import drag-and-drop & loading ---
|
||||
(function() {
|
||||
const dropzone = document.getElementById('domainDropzone');
|
||||
const fileInput = document.getElementById('domainFileInput');
|
||||
@@ -371,7 +367,7 @@ updateTagsDisplay();
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.remove('border-gray-300', 'dark:border-slate-600');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
@@ -379,7 +375,7 @@ updateTagsDisplay();
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.add('border-gray-300', 'dark:border-slate-600');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
@@ -397,7 +393,7 @@ updateTagsDisplay();
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.remove('border-gray-300', 'dark:border-slate-600');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -406,7 +402,7 @@ updateTagsDisplay();
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.add('border-gray-300', 'dark:border-slate-600');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -426,9 +422,4 @@ updateTagsDisplay();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,57 +1,58 @@
|
||||
<?php
|
||||
$title = 'Add New Domain';
|
||||
$pageTitle = 'Add New Domain';
|
||||
$pageDescription = 'Start monitoring a new domain';
|
||||
$pageIcon = 'fas fa-plus-circle';
|
||||
ob_start();
|
||||
?>
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Add New Domain' %}
|
||||
{% set pageTitle = 'Add New Domain' %}
|
||||
{% set pageDescription = 'Start monitoring a new domain' %}
|
||||
{% set pageIcon = 'fas fa-plus-circle' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Main Form -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-globe text-gray-400 mr-2 text-sm"></i>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
|
||||
Domain Information
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/domains/store" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<!-- Domain Name -->
|
||||
<div>
|
||||
<label for="domain_name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="domain_name" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Domain Name *
|
||||
</label>
|
||||
<input type="text"
|
||||
id="domain_name"
|
||||
name="domain_name"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="example.com"
|
||||
required
|
||||
autofocus>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Enter the domain name without http:// or https://
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label for="tags-input" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="tags-input" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Tags
|
||||
<span class="text-gray-400 font-normal">(Optional)</span>
|
||||
<span class="text-gray-400 dark:text-slate-500 font-normal">(Optional)</span>
|
||||
</label>
|
||||
|
||||
<!-- Tag Display Area -->
|
||||
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50"></div>
|
||||
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 dark:border-slate-600 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50 dark:bg-slate-900"></div>
|
||||
|
||||
<!-- Tag Input -->
|
||||
<div class="relative">
|
||||
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-sm"></i>
|
||||
<input type="text"
|
||||
id="tags-input"
|
||||
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Type any tag and press Enter or comma..."
|
||||
onkeydown="handleTagInput(event)">
|
||||
<button type="button" onclick="addTagFromInput()" class="absolute right-2 top-1/2 transform -translate-y-1/2 px-3 py-1 bg-primary text-white text-xs rounded hover:bg-primary-dark">
|
||||
@@ -62,40 +63,40 @@ ob_start();
|
||||
<!-- Hidden input to store tags for form submission -->
|
||||
<input type="hidden" id="tags" name="tags" value="">
|
||||
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">,</kbd> to add.
|
||||
Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 dark:bg-slate-600 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 dark:bg-slate-600 rounded text-xs">,</kbd> to add.
|
||||
</p>
|
||||
|
||||
<!-- Available Tags -->
|
||||
<div class="mt-2">
|
||||
<p class="text-xs text-gray-600 mb-1.5">💡 Available Tags:</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80 transition-colors">
|
||||
{% for tag in availableTags %}
|
||||
<button type="button" onclick="addTag('{{ tag.name }}')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Group -->
|
||||
<div>
|
||||
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Notification Group
|
||||
</label>
|
||||
<select id="notification_group_id"
|
||||
name="notification_group_id"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
<option value="">-- No Group (No notifications) --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}">{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Optional: Assign to a notification group to receive expiry alerts
|
||||
</p>
|
||||
</div>
|
||||
@@ -108,7 +109,7 @@ ob_start();
|
||||
Add Domain
|
||||
</button>
|
||||
<a href="/domains"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
@@ -120,7 +121,7 @@ ob_start();
|
||||
<!-- Info Cards -->
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- How it works -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
@@ -128,8 +129,8 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3>
|
||||
<p class="text-xs text-gray-600 leading-relaxed">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">How It Works</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 leading-relaxed">
|
||||
When you add a domain, we automatically fetch its WHOIS information including
|
||||
expiration date, registrar, nameservers, and other important details. This may take a few seconds.
|
||||
</p>
|
||||
@@ -138,7 +139,7 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<!-- What we track -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
|
||||
@@ -146,8 +147,8 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">What We Track</h3>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">What We Track</h3>
|
||||
<ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
|
||||
<li class="flex items-center">
|
||||
<i class="fas fa-circle text-green-500" style="font-size: 6px;"></i>
|
||||
<span class="ml-2">Domain expiration date</span>
|
||||
@@ -171,11 +172,13 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let tags = [];
|
||||
|
||||
// Available tags with their colors from the database
|
||||
const availableTags = <?= json_encode($availableTags) ?>;
|
||||
const availableTags = {{ availableTags|json_encode|raw }};
|
||||
const tagColors = {};
|
||||
availableTags.forEach(tag => {
|
||||
tagColors[tag.name] = tag.color;
|
||||
@@ -184,12 +187,10 @@ availableTags.forEach(tag => {
|
||||
function addTag(tagName) {
|
||||
tagName = tagName.trim().toLowerCase();
|
||||
|
||||
// Validate tag (alphanumeric and hyphens only)
|
||||
if (!/^[a-z0-9-]+$/.test(tagName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tag already exists
|
||||
if (tags.includes(tagName)) {
|
||||
return;
|
||||
}
|
||||
@@ -198,7 +199,6 @@ function addTag(tagName) {
|
||||
updateTagsDisplay();
|
||||
updateHiddenInput();
|
||||
|
||||
// Clear input
|
||||
document.getElementById('tags-input').value = '';
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ function updateTagsDisplay() {
|
||||
display.innerHTML = '';
|
||||
|
||||
if (tags.length === 0) {
|
||||
display.innerHTML = '<span class="text-xs text-gray-400 italic">No tags added yet</span>';
|
||||
display.innerHTML = '<span class="text-xs text-gray-400 dark:text-slate-500 italic">No tags added yet</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,18 +248,12 @@ function addTagFromInput() {
|
||||
const value = input.value.trim();
|
||||
|
||||
if (value) {
|
||||
// Handle multiple tags separated by commas
|
||||
const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t);
|
||||
newTags.forEach(tag => addTag(tag));
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize display
|
||||
updateTagsDisplay();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
@@ -1,60 +1,61 @@
|
||||
<?php
|
||||
$title = 'Edit Domain';
|
||||
$pageTitle = 'Edit Domain';
|
||||
$pageDescription = htmlspecialchars($domain['domain_name']);
|
||||
$pageIcon = 'fas fa-edit';
|
||||
ob_start();
|
||||
?>
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Edit Domain' %}
|
||||
{% set pageTitle = 'Edit Domain' %}
|
||||
{% set pageDescription = domain.domain_name %}
|
||||
{% set pageIcon = 'fas fa-edit' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Main Form -->
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-cog text-gray-400 mr-2 text-sm"></i>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-cog text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
|
||||
Domain Settings
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/update" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/update" class="space-y-5">
|
||||
{{ csrf_field() }}
|
||||
|
||||
<!-- Domain Name (Read-only) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Domain Name
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-gray-50 text-gray-600 cursor-not-allowed text-sm"
|
||||
value="<?= htmlspecialchars($domain['domain_name']) ?>"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-50 dark:bg-slate-900 text-gray-600 dark:text-slate-400 cursor-not-allowed text-sm"
|
||||
value="{{ domain.domain_name }}"
|
||||
disabled>
|
||||
<div class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<i class="fas fa-lock text-gray-400 text-xs"></i>
|
||||
<i class="fas fa-lock text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Domain name cannot be changed after creation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label for="tags-input" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="tags-input" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Tags
|
||||
<span class="text-gray-400 font-normal">(Optional)</span>
|
||||
<span class="text-gray-400 dark:text-slate-500 font-normal">(Optional)</span>
|
||||
</label>
|
||||
|
||||
<!-- Tag Display Area -->
|
||||
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50"></div>
|
||||
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 dark:border-slate-600 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50 dark:bg-slate-900"></div>
|
||||
|
||||
<!-- Tag Input -->
|
||||
<div class="relative">
|
||||
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-sm"></i>
|
||||
<input type="text"
|
||||
id="tags-input"
|
||||
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
placeholder="Type any tag and press Enter or comma..."
|
||||
onkeydown="handleTagInput(event)">
|
||||
<button type="button" onclick="addTagFromInput()" class="absolute right-2 top-1/2 transform -translate-y-1/2 px-3 py-1 bg-primary text-white text-xs rounded hover:bg-primary-dark">
|
||||
@@ -63,91 +64,91 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<!-- Hidden input to store tags for form submission -->
|
||||
<input type="hidden" id="tags" name="tags" value="<?= htmlspecialchars($domain['tags'] ?? '') ?>">
|
||||
<input type="hidden" id="tags" name="tags" value="{{ domain.tags|default('') }}">
|
||||
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">,</kbd> to add.
|
||||
Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 dark:bg-slate-600 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 dark:bg-slate-600 rounded text-xs">,</kbd> to add.
|
||||
</p>
|
||||
|
||||
<!-- Available Tags -->
|
||||
<div class="mt-2">
|
||||
<p class="text-xs text-gray-600 mb-1.5">💡 Available Tags:</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mb-1.5">💡 Available Tags:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80 transition-colors">
|
||||
{% for tag in availableTags %}
|
||||
<button type="button" onclick="addTag('{{ tag.name }}')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border {{ tag.color }} hover:opacity-80 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
{{ tag.name }}
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Group -->
|
||||
<div>
|
||||
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Notification Group
|
||||
</label>
|
||||
<select id="notification_group_id"
|
||||
name="notification_group_id"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
<option value="">-- No Group (No notifications) --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"
|
||||
<?= $domain['notification_group_id'] == $group['id'] ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($group['name']) ?>
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}"
|
||||
{{ domain.notification_group_id == group.id ? 'selected' : '' }}>
|
||||
{{ group.name }}
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Change the notification group or remove it to stop receiving alerts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Manual Expiration Date -->
|
||||
<div>
|
||||
<label for="manual_expiration_date" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="manual_expiration_date" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Manual Expiration Date
|
||||
<span class="text-gray-400 font-normal">(Optional)</span>
|
||||
<span class="text-gray-400 dark:text-slate-500 font-normal">(Optional)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<i class="fas fa-calendar-alt absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||
<i class="fas fa-calendar-alt absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-sm"></i>
|
||||
<input type="date"
|
||||
id="manual_expiration_date"
|
||||
name="manual_expiration_date"
|
||||
value="<?= $domain['expiration_date'] ? date('Y-m-d', strtotime($domain['expiration_date'])) : '' ?>"
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
value="{{ domain.expiration_date ? domain.expiration_date|date('Y-m-d') : '' }}"
|
||||
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Set a manual expiration date if WHOIS/RDAP doesn't provide one (e.g., for .nl domains).
|
||||
This will be used for expiration notifications and status calculations.
|
||||
</p>
|
||||
<?php if ($domain['expiration_date']): ?>
|
||||
<p class="mt-1 text-xs text-green-600">
|
||||
{% if domain.expiration_date %}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
Current expiration date: <?= $domain['expiration_date'] ? date('M j, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?>
|
||||
Current expiration date: {{ domain.expiration_date|date('M j, Y') }}
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<p class="mt-1 text-xs text-amber-600">
|
||||
{% else %}
|
||||
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
No expiration date available from WHOIS/RDAP. Consider setting a manual date.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Active Monitoring -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox"
|
||||
name="is_active"
|
||||
<?= $domain['is_active'] ? 'checked' : '' ?>
|
||||
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary cursor-pointer">
|
||||
{{ domain.is_active ? 'checked' : '' }}
|
||||
class="w-4 h-4 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-medium text-gray-900">Enable Active Monitoring</span>
|
||||
<p class="text-xs text-gray-600 mt-0.5">When enabled, this domain will be checked regularly and notifications will be sent</p>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable Active Monitoring</span>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">When enabled, this domain will be checked regularly and notifications will be sent</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -159,8 +160,8 @@ ob_start();
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Update Domain
|
||||
</button>
|
||||
<a href="<?= htmlspecialchars($referrer ?? '/domains/' . $domain['id']) ?>"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<a href="{{ referrer|default('/domains/' ~ domain.id) }}"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
@@ -171,37 +172,38 @@ ob_start();
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<a href="/domains/<?= $domain['id'] ?>"
|
||||
class="flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-colors group">
|
||||
<i class="fas fa-eye text-blue-600 mr-2 text-sm"></i>
|
||||
<span class="text-sm font-medium text-gray-700">View Details</span>
|
||||
<a href="/domains/{{ domain.id }}"
|
||||
class="flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors group">
|
||||
<i class="fas fa-eye text-blue-600 dark:text-blue-400 mr-2 text-sm"></i>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">View Details</span>
|
||||
</a>
|
||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="m-0">
|
||||
<?= csrf_field() ?>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="m-0">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit"
|
||||
class="w-full flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-green-300 hover:bg-green-50 transition-colors group">
|
||||
<i class="fas fa-sync-alt text-green-600 mr-2 text-sm"></i>
|
||||
<span class="text-sm font-medium text-gray-700">Refresh WHOIS</span>
|
||||
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-green-300 dark:hover:border-green-700 hover:bg-green-50 dark:hover:bg-green-500/10 transition-colors group">
|
||||
<i class="fas fa-sync-alt text-green-600 dark:text-green-400 mr-2 text-sm"></i>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">Refresh WHOIS</span>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" onsubmit="return confirm('Delete this domain permanently?')" class="m-0">
|
||||
<?= csrf_field() ?>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain permanently?')" class="m-0">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit"
|
||||
class="w-full flex items-center justify-center p-3 bg-white border border-gray-200 rounded-lg hover:border-red-300 hover:bg-red-50 transition-colors group">
|
||||
<i class="fas fa-trash text-red-600 mr-2 text-sm"></i>
|
||||
<span class="text-sm font-medium text-gray-700">Delete Domain</span>
|
||||
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-red-300 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors group">
|
||||
<i class="fas fa-trash text-red-600 dark:text-red-400 mr-2 text-sm"></i>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">Delete Domain</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Initialize tags from existing domain data
|
||||
const existingTags = '<?= htmlspecialchars($domain['tags'] ?? '') ?>';
|
||||
const existingTags = {{ domain.tags|default('')|json_encode|raw }};
|
||||
let tags = existingTags ? existingTags.split(',').map(t => t.trim().toLowerCase()).filter(t => t) : [];
|
||||
|
||||
// Available tags with their colors from the database
|
||||
const availableTags = <?= json_encode($availableTags) ?>;
|
||||
const availableTags = {{ availableTags|json_encode|raw }};
|
||||
const tagColors = {};
|
||||
availableTags.forEach(tag => {
|
||||
tagColors[tag.name] = tag.color;
|
||||
@@ -210,12 +212,10 @@ availableTags.forEach(tag => {
|
||||
function addTag(tagName) {
|
||||
tagName = tagName.trim().toLowerCase();
|
||||
|
||||
// Validate tag (alphanumeric and hyphens only)
|
||||
if (!/^[a-z0-9-]+$/.test(tagName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tag already exists
|
||||
if (tags.includes(tagName)) {
|
||||
return;
|
||||
}
|
||||
@@ -224,7 +224,6 @@ function addTag(tagName) {
|
||||
updateTagsDisplay();
|
||||
updateHiddenInput();
|
||||
|
||||
// Clear input
|
||||
document.getElementById('tags-input').value = '';
|
||||
}
|
||||
|
||||
@@ -239,7 +238,7 @@ function updateTagsDisplay() {
|
||||
display.innerHTML = '';
|
||||
|
||||
if (tags.length === 0) {
|
||||
display.innerHTML = '<span class="text-xs text-gray-400 italic">No tags added yet</span>';
|
||||
display.innerHTML = '<span class="text-xs text-gray-400 dark:text-slate-500 italic">No tags added yet</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -274,18 +273,12 @@ function addTagFromInput() {
|
||||
const value = input.value.trim();
|
||||
|
||||
if (value) {
|
||||
// Handle multiple tags separated by commas
|
||||
const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t);
|
||||
newTags.forEach(tag => addTag(tag));
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize display
|
||||
updateTagsDisplay();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,446 +0,0 @@
|
||||
<?php
|
||||
$title = 'Domain Details';
|
||||
$pageTitle = htmlspecialchars($domain['domain_name']);
|
||||
$pageDescription = 'Domain information and monitoring status';
|
||||
$pageIcon = 'fas fa-globe';
|
||||
|
||||
// Data already formatted by controller via DomainHelper
|
||||
$whoisData = json_decode($domain['whois_data'] ?? '{}', true);
|
||||
$daysLeft = $domain['daysLeft'];
|
||||
$domainStatus = $domain['displayStatus'];
|
||||
$expiryColor = $domain['expiryColor'];
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<!-- Top Action Bar -->
|
||||
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<?php
|
||||
// Status badge data prepared by DomainHelper in controller
|
||||
$statusClass = $domain['statusClass'];
|
||||
$statusText = $domain['statusText'];
|
||||
$statusIcon = $domain['statusIcon'];
|
||||
?>
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold <?= $statusClass ?>">
|
||||
<i class="fas <?= $statusIcon ?> mr-1.5"></i>
|
||||
<?= $statusText ?>
|
||||
</span>
|
||||
<?php if ($domainStatus !== 'available'): ?>
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-<?= $expiryColor ?>-100 text-<?= $expiryColor ?>-800 border border-<?= $expiryColor ?>-200">
|
||||
<i class="fas fa-calendar-alt mr-1.5"></i>
|
||||
<?= $daysLeft !== null ? $daysLeft . ' days left' : 'No expiry date' ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-indigo-100 text-indigo-800 border border-indigo-200">
|
||||
<i class="fas fa-<?= $domain['is_active'] ? 'check-circle' : 'pause-circle' ?> mr-1.5"></i>
|
||||
<?= $domain['is_active'] ? 'Monitoring Active' : 'Monitoring Paused' ?>
|
||||
</span>
|
||||
|
||||
<!-- Tags Display -->
|
||||
<?php
|
||||
$tags = !empty($domain['tags']) ? explode(',', $domain['tags']) : [];
|
||||
$tagColors = !empty($domain['tag_colors']) ? explode('|', $domain['tag_colors']) : [];
|
||||
|
||||
// Create a mapping of tag names to their colors
|
||||
$tagColorMap = [];
|
||||
foreach ($availableTags as $availableTag) {
|
||||
$tagColorMap[$availableTag['name']] = $availableTag['color'];
|
||||
}
|
||||
|
||||
foreach ($tags as $index => $tag):
|
||||
$tag = trim($tag);
|
||||
// Use the color from the database if available, otherwise use the stored color, otherwise default
|
||||
$colorClass = $tagColorMap[$tag] ?? (isset($tagColors[$index]) ? $tagColors[$index] : 'bg-gray-100 text-gray-700 border-gray-200');
|
||||
?>
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold border <?= $colorClass ?>">
|
||||
<i class="fas fa-tag mr-1.5" style="font-size: 10px;"></i>
|
||||
<?= htmlspecialchars(ucfirst($tag)) ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||
Refresh
|
||||
</button>
|
||||
</form>
|
||||
<a href="/domains/<?= $domain['id'] ?>/edit?from=/domains/<?= $domain['id'] ?>" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-edit mr-1.5"></i>
|
||||
Edit
|
||||
</a>
|
||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-trash mr-1.5"></i>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<a href="/domains" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-gray-700 text-xs rounded-lg hover:bg-gray-50 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-arrow-left mr-1.5"></i>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main 2-Column Layout -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
|
||||
<!-- LEFT COLUMN -->
|
||||
<div class="space-y-3">
|
||||
|
||||
<!-- Registration Details -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-building text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Registration Details
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
|
||||
<div>
|
||||
<label class="text-gray-500 font-medium block mb-0.5">Registrar</label>
|
||||
<p class="text-gray-900 font-semibold"><?= htmlspecialchars($domain['registrar'] ?? 'Unknown') ?></p>
|
||||
</div>
|
||||
<?php if (!empty($domain['registrar_url'])): ?>
|
||||
<div>
|
||||
<label class="text-gray-500 font-medium block mb-0.5">Registrar URL</label>
|
||||
<a href="<?= htmlspecialchars($domain['registrar_url']) ?>" target="_blank" class="text-blue-600 hover:text-blue-800 flex items-center">
|
||||
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||
Visit
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($domain['abuse_email'])): ?>
|
||||
<div>
|
||||
<label class="text-gray-500 font-medium block mb-0.5">Abuse Contact</label>
|
||||
<a href="mailto:<?= htmlspecialchars($domain['abuse_email']) ?>" class="text-blue-600 hover:text-blue-800">
|
||||
<?= htmlspecialchars($domain['abuse_email']) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($whoisData['whois_server'])): ?>
|
||||
<div>
|
||||
<label class="text-gray-500 font-medium block mb-0.5">WHOIS Server</label>
|
||||
<p class="text-gray-900 font-mono"><?= htmlspecialchars($whoisData['whois_server']) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($whoisData['owner'])): ?>
|
||||
<div class="col-span-2">
|
||||
<label class="text-gray-500 font-medium block mb-0.5">Owner</label>
|
||||
<p class="text-gray-900"><?= htmlspecialchars($whoisData['owner']) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important Dates -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-calendar text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Important Dates
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-2">
|
||||
<?php if (!empty($domain['expiration_date'])): ?>
|
||||
<div class="flex items-center justify-between p-2 bg-<?= $expiryColor ?>-50 rounded border border-<?= $expiryColor ?>-200">
|
||||
<div class="flex items-center">
|
||||
<div class="w-7 h-7 bg-<?= $expiryColor ?>-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-exclamation-triangle text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 font-medium">
|
||||
Expiration
|
||||
<?php if ($domain['isManualExpiration']): ?>
|
||||
<span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
|
||||
<i class="fas fa-edit mr-1" style="font-size: 8px;"></i>
|
||||
Manual
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<p class="text-xs font-semibold text-gray-900"><?= $domain['expiration_date'] ? date('M j, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 bg-<?= $expiryColor ?>-100 text-<?= $expiryColor ?>-800 rounded text-xs font-bold">
|
||||
<?= $daysLeft ?> days
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($domain['updated_date'])): ?>
|
||||
<div class="flex items-center p-2 bg-blue-50 rounded border border-blue-200">
|
||||
<div class="w-7 h-7 bg-blue-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-clock text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 font-medium">Last Updated</p>
|
||||
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y', strtotime($domain['updated_date'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($whoisData['creation_date'])): ?>
|
||||
<div class="flex items-center p-2 bg-green-50 rounded border border-green-200">
|
||||
<div class="w-7 h-7 bg-green-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-calendar-plus text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 font-medium">Created</p>
|
||||
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y', strtotime($whoisData['creation_date'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="flex items-center p-2 bg-indigo-50 rounded border border-indigo-200">
|
||||
<div class="w-7 h-7 bg-indigo-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-sync text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 font-medium">Last Checked</p>
|
||||
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y H:i', strtotime($domain['last_checked'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nameservers -->
|
||||
<?php if (!empty($whoisData['nameservers'])): ?>
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-server text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Nameservers (<?= count($whoisData['nameservers']) ?>)
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-1.5">
|
||||
<?php foreach ($whoisData['nameservers'] as $index => $ns): ?>
|
||||
<div class="flex items-center p-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors">
|
||||
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
|
||||
<?= $index + 1 ?>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-800"><?= htmlspecialchars($ns) ?></p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Domain Status -->
|
||||
<?php if (!empty($domain['parsedStatuses'])): ?>
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-info-circle text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Domain Status (<?= count($domain['parsedStatuses']) ?>)
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<?php foreach ($domain['parsedStatuses'] as $cleanStatus): ?>
|
||||
<?php
|
||||
// Format status text using helper
|
||||
$readableStatus = \App\Helpers\DomainHelper::formatStatusText($cleanStatus);
|
||||
?>
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium" title="<?= htmlspecialchars($cleanStatus) ?>">
|
||||
<?= htmlspecialchars($readableStatus) ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN -->
|
||||
<div class="space-y-3">
|
||||
|
||||
<!-- Notification Group -->
|
||||
<?php if (!empty($domain['group_name'])): ?>
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-bell text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Notification Group
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-users text-green-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-sm text-gray-900"><?= htmlspecialchars($domain['group_name']) ?></p>
|
||||
<?php if (!empty($domain['channels'])): ?>
|
||||
<p class="text-xs text-gray-600">
|
||||
<?= $domain['activeChannelCount'] ?? 0 ?> / <?= count($domain['channels']) ?> channels active
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!empty($domain['channels'])): ?>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<?php foreach ($domain['channels'] as $channel): ?>
|
||||
<div class="flex items-center p-2 rounded <?= $channel['is_active'] ? 'bg-green-50 border border-green-200' : 'bg-gray-50 border border-gray-200' ?>">
|
||||
<i class="fas fa-<?= $channel['is_active'] ? 'check-circle text-green-600' : 'times-circle text-gray-400' ?> mr-2 text-xs"></i>
|
||||
<span class="text-xs font-medium text-gray-700"><?= ucfirst($channel['channel_type']) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="bg-orange-50 rounded-lg border border-orange-200 p-4">
|
||||
<div class="flex items-start mb-2">
|
||||
<i class="fas fa-exclamation-triangle text-orange-500 mr-2 mt-0.5"></i>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-900">No Group Assigned</h3>
|
||||
<p class="text-xs text-gray-600 mt-0.5">Won't receive notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/domains/<?= $domain['id'] ?>/edit?from=/domains/<?= $domain['id'] ?>" class="block w-full text-center px-3 py-1.5 bg-orange-500 text-white text-xs rounded-lg hover:bg-orange-600 transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
Assign Group
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-sticky-note text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Notes
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/update-notes" id="notes-form">
|
||||
<?= csrf_field() ?>
|
||||
<textarea
|
||||
name="notes"
|
||||
id="notes-textarea"
|
||||
rows="6"
|
||||
class="w-full px-3 py-2 text-xs border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
placeholder="Add notes about this domain..."><?= htmlspecialchars($domain['notes'] ?? '') ?></textarea>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-save mr-1.5"></i>
|
||||
Update Notes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="resetNotes()"
|
||||
class="flex-1 inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-gray-700 text-xs rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-1.5"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification History -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-history text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Notification History (<?= count($logs) ?>)
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<?php if (empty($logs)): ?>
|
||||
<div class="p-8 text-center">
|
||||
<i class="fas fa-bell-slash text-gray-300 text-3xl mb-2"></i>
|
||||
<p class="text-xs text-gray-500">No notifications sent yet</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-xs">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Channel</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Status</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Date</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($logs as $log): ?>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<?= ucfirst($log['channel_type']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<?php $statusClass = $log['status'] === 'sent' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'; ?>
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium <?= $statusClass ?>">
|
||||
<?= ucfirst($log['status']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-gray-600"><?= date('M j, H:i', strtotime($log['sent_at'])) ?></td>
|
||||
<td class="px-3 py-2 text-gray-700 max-w-xs truncate" title="<?= htmlspecialchars($log['message']) ?>">
|
||||
<?= htmlspecialchars($log['message']) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw WHOIS Data (Collapsible) -->
|
||||
<?php if (!empty($domain['whois_data'])): ?>
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button onclick="toggleWhoisData()" class="w-full px-4 py-2 border-b border-gray-200 bg-gray-50 text-left hover:bg-gray-100 transition-colors">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-code text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Raw WHOIS Data
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="whois-chevron"></i>
|
||||
</h3>
|
||||
</button>
|
||||
<div id="whois-data" class="hidden p-4 bg-gray-900 max-h-64 overflow-y-auto">
|
||||
<pre class="text-xs text-green-400 font-mono"><?= htmlspecialchars(json_encode($whoisData, JSON_PRETTY_PRINT)) ?></pre>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleWhoisData() {
|
||||
const dataDiv = document.getElementById('whois-data');
|
||||
const chevron = document.getElementById('whois-chevron');
|
||||
dataDiv.classList.toggle('hidden');
|
||||
chevron.classList.toggle('rotate-180');
|
||||
}
|
||||
|
||||
function resetNotes() {
|
||||
const originalNotes = <?= json_encode($domain['notes'] ?? '') ?>;
|
||||
document.getElementById('notes-textarea').value = originalNotes;
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
431
app/Views/domains/view.twig
Normal file
431
app/Views/domains/view.twig
Normal file
@@ -0,0 +1,431 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Domain Details' %}
|
||||
{% set pageTitle = domain.domain_name %}
|
||||
{% set pageDescription = 'Domain information and monitoring status' %}
|
||||
{% set pageIcon = 'fas fa-globe' %}
|
||||
|
||||
{% set daysLeft = domain.daysLeft %}
|
||||
{% set domainStatus = domain.displayStatus %}
|
||||
{% set expiryColor = domain.expiryColor %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Top Action Bar -->
|
||||
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold {{ domain.statusClass }}">
|
||||
<i class="fas {{ domain.statusIcon }} mr-1.5"></i>
|
||||
{{ domain.statusText }}
|
||||
</span>
|
||||
{% if domainStatus != 'available' %}
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-{{ expiryColor }}-100 text-{{ expiryColor }}-800 dark:bg-{{ expiryColor }}-500/10 dark:text-{{ expiryColor }}-400 border border-{{ expiryColor }}-200 dark:border-{{ expiryColor }}-800">
|
||||
<i class="fas fa-calendar-alt mr-1.5"></i>
|
||||
{{ daysLeft is not null ? daysLeft ~ ' days left' : 'No expiry date' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-indigo-100 dark:bg-indigo-500/10 text-indigo-800 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800">
|
||||
<i class="fas fa-{{ domain.is_active ? 'check-circle' : 'pause-circle' }} mr-1.5"></i>
|
||||
{{ domain.is_active ? 'Monitoring Active' : 'Monitoring Paused' }}
|
||||
</span>
|
||||
|
||||
<!-- Tags Display -->
|
||||
{% set domainTags = domain.tags ? domain.tags|split(',') : [] %}
|
||||
{% set tagColorList = domain.tag_colors ? domain.tag_colors|split('|') : [] %}
|
||||
|
||||
{% for tag in domainTags %}
|
||||
{% set tagName = tag|trim %}
|
||||
{% set matchedTags = availableTags|filter(t => t.name == tagName) %}
|
||||
{% if matchedTags|length > 0 %}
|
||||
{% set colorClass = matchedTags|first.color %}
|
||||
{% elseif tagColorList[loop.index0] is defined %}
|
||||
{% set colorClass = tagColorList[loop.index0] %}
|
||||
{% else %}
|
||||
{% set colorClass = 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 border-gray-200 dark:border-slate-700' %}
|
||||
{% endif %}
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold border {{ colorClass }}">
|
||||
<i class="fas fa-tag mr-1.5" style="font-size: 10px;"></i>
|
||||
{{ tagName|capitalize }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||
Refresh
|
||||
</button>
|
||||
</form>
|
||||
<a href="/domains/{{ domain.id }}/edit?from=/domains/{{ domain.id }}" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-edit mr-1.5"></i>
|
||||
Edit
|
||||
</a>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-trash mr-1.5"></i>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<a href="/domains" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-arrow-left mr-1.5"></i>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main 2-Column Layout -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
|
||||
<!-- LEFT COLUMN -->
|
||||
<div class="space-y-3">
|
||||
|
||||
<!-- Registration Details -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-building text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Registration Details
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar</label>
|
||||
<p class="text-gray-900 dark:text-white font-semibold">{{ domain.registrar|default('Unknown') }}</p>
|
||||
</div>
|
||||
{% if domain.registrar_url is not empty %}
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label>
|
||||
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
|
||||
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||
Visit
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if domain.abuse_email is not empty %}
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
|
||||
<a href="mailto:{{ domain.abuse_email }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
{{ domain.abuse_email }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if whoisData.whois_server is defined %}
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">WHOIS Server</label>
|
||||
<p class="text-gray-900 dark:text-white font-mono">{{ whoisData.whois_server }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if whoisData.owner is defined %}
|
||||
<div class="col-span-2">
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Owner</label>
|
||||
<p class="text-gray-900 dark:text-white">{{ whoisData.owner }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important Dates -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-calendar text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Important Dates
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-2">
|
||||
{% if domain.expiration_date is not empty %}
|
||||
<div class="flex items-center justify-between p-2 bg-{{ expiryColor }}-50 dark:bg-{{ expiryColor }}-500/10 rounded border border-{{ expiryColor }}-200 dark:border-{{ expiryColor }}-800">
|
||||
<div class="flex items-center">
|
||||
<div class="w-7 h-7 bg-{{ expiryColor }}-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-exclamation-triangle text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">
|
||||
Expiration
|
||||
{% if domain.isManualExpiration %}
|
||||
<span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-500/10 text-amber-800 dark:text-amber-400">
|
||||
<i class="fas fa-edit mr-1" style="font-size: 8px;"></i>
|
||||
Manual
|
||||
</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-xs font-semibold text-gray-900 dark:text-white">{{ domain.expiration_date ? domain.expiration_date|date('M j, Y') : 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 bg-{{ expiryColor }}-100 dark:bg-{{ expiryColor }}-500/20 text-{{ expiryColor }}-800 dark:text-{{ expiryColor }}-400 rounded text-xs font-bold">
|
||||
{{ daysLeft }} days
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if domain.updated_date is not empty %}
|
||||
<div class="flex items-center p-2 bg-blue-50 dark:bg-blue-500/10 rounded border border-blue-200 dark:border-blue-800">
|
||||
<div class="w-7 h-7 bg-blue-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-clock text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Last Updated</p>
|
||||
<p class="text-xs font-semibold text-gray-900 dark:text-white">{{ domain.updated_date|date('M j, Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if whoisData.creation_date is defined %}
|
||||
<div class="flex items-center p-2 bg-green-50 dark:bg-green-500/10 rounded border border-green-200 dark:border-green-800">
|
||||
<div class="w-7 h-7 bg-green-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-calendar-plus text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Created</p>
|
||||
<p class="text-xs font-semibold text-gray-900 dark:text-white">{{ whoisData.creation_date|date('M j, Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center p-2 bg-indigo-50 dark:bg-indigo-500/10 rounded border border-indigo-200 dark:border-indigo-800">
|
||||
<div class="w-7 h-7 bg-indigo-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-sync text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Last Checked</p>
|
||||
<p class="text-xs font-semibold text-gray-900 dark:text-white">{{ domain.last_checked|date('M j, Y H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nameservers -->
|
||||
{% if whoisData.nameservers is not empty %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-server text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Nameservers ({{ whoisData.nameservers|length }})
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-1.5">
|
||||
{% for ns in whoisData.nameservers %}
|
||||
<div class="flex items-center p-2 bg-gray-50 dark:bg-slate-700 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
|
||||
{{ loop.index }}
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-800 dark:text-slate-200">{{ ns }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Domain Status -->
|
||||
{% if domain.parsedStatuses is not empty %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-info-circle text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Domain Status ({{ domain.parsedStatuses|length }})
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for status in domain.parsedStatuses %}
|
||||
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 rounded text-xs font-medium" title="{{ status }}">
|
||||
{{ status }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN -->
|
||||
<div class="space-y-3">
|
||||
|
||||
<!-- Notification Group -->
|
||||
{% if domain.group_name is not empty %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-bell text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Notification Group
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="w-10 h-10 bg-green-100 dark:bg-green-500/10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-users text-green-600 dark:text-green-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-sm text-gray-900 dark:text-white">{{ domain.group_name }}</p>
|
||||
{% if domain.channels is not empty %}
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">
|
||||
{{ domain.activeChannelCount|default(0) }} / {{ domain.channels|length }} channels active
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if domain.channels is not empty %}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{% for channel in domain.channels %}
|
||||
<div class="flex items-center p-2 rounded {{ channel.is_active ? 'bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-800' : 'bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-700' }}">
|
||||
<i class="fas fa-{{ channel.is_active ? 'check-circle text-green-600 dark:text-green-400' : 'times-circle text-gray-400 dark:text-slate-500' }} mr-2 text-xs"></i>
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-slate-300">{{ channel.channel_type|capitalize }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-orange-50 dark:bg-orange-500/10 rounded-lg border border-orange-200 dark:border-orange-800 p-4">
|
||||
<div class="flex items-start mb-2">
|
||||
<i class="fas fa-exclamation-triangle text-orange-500 dark:text-orange-400 mr-2 mt-0.5"></i>
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">No Group Assigned</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">Won't receive notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/domains/{{ domain.id }}/edit?from=/domains/{{ domain.id }}" class="block w-full text-center px-3 py-1.5 bg-orange-500 text-white text-xs rounded-lg hover:bg-orange-600 transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
Assign Group
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-sticky-note text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Notes
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<form method="POST" action="/domains/{{ domain.id }}/update-notes" id="notes-form">
|
||||
{{ csrf_field() }}
|
||||
<textarea
|
||||
name="notes"
|
||||
id="notes-textarea"
|
||||
rows="6"
|
||||
class="w-full px-3 py-2 text-xs border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
placeholder="Add notes about this domain...">{{ domain.notes|default('') }}</textarea>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-save mr-1.5"></i>
|
||||
Update Notes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="resetNotes()"
|
||||
class="flex-1 inline-flex items-center justify-center px-3 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-1.5"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification History -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-history text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Notification History ({{ logs|length }})
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
{% if logs is empty %}
|
||||
<div class="p-8 text-center">
|
||||
<i class="fas fa-bell-slash text-gray-300 dark:text-slate-600 text-3xl mb-2"></i>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">No notifications sent yet</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700 text-xs">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Channel</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Status</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Date</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for log in logs %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400">
|
||||
{{ log.channel_type|capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
{% set logStatusClass = log.status == 'sent' ? 'bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400' : 'bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400' %}
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium {{ logStatusClass }}">
|
||||
{{ log.status|capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap text-gray-600 dark:text-slate-400">{{ log.sent_at|date('M j, H:i') }}</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-slate-300 max-w-xs truncate" title="{{ log.message }}">
|
||||
{{ log.message }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw WHOIS Data (Collapsible) -->
|
||||
{% if domain.whois_data is not empty %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<button onclick="toggleWhoisData()" class="w-full px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 text-left hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-code text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Raw WHOIS Data
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 dark:text-slate-500 text-xs transition-transform" id="whois-chevron"></i>
|
||||
</h3>
|
||||
</button>
|
||||
<div id="whois-data" class="hidden p-4 bg-gray-900 max-h-64 overflow-y-auto">
|
||||
<pre class="text-xs text-green-400 font-mono">{{ whoisData|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function toggleWhoisData() {
|
||||
const dataDiv = document.getElementById('whois-data');
|
||||
const chevron = document.getElementById('whois-chevron');
|
||||
dataDiv.classList.toggle('hidden');
|
||||
chevron.classList.toggle('rotate-180');
|
||||
}
|
||||
|
||||
function resetNotes() {
|
||||
const originalNotes = {{ domain.notes|default('')|json_encode|raw }};
|
||||
document.getElementById('notes-textarea').value = originalNotes;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -5,10 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 - Page Not Found</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<script>
|
||||
@@ -29,19 +26,16 @@
|
||||
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen flex items-center justify-center p-6">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="bg-white rounded-2xl shadow-2xl p-12 text-center">
|
||||
<!-- 404 Icon -->
|
||||
<div class="mb-8">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500 text-8xl mb-4 animate-pulse"></i>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<h1 class="text-9xl font-bold text-gray-800 mb-4">404</h1>
|
||||
<h2 class="text-3xl font-bold text-gray-700 mb-4">Page Not Found</h2>
|
||||
<p class="text-gray-600 text-lg mb-8 leading-relaxed">
|
||||
Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
|
||||
</p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/" class="inline-flex items-center justify-center px-8 py-4 bg-primary text-white rounded-lg hover:bg-primary-dark transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
|
||||
<i class="fas fa-home mr-2"></i>
|
||||
@@ -53,7 +47,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Helpful Links -->
|
||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
||||
<p class="text-sm text-gray-500 mb-4">Quick Links:</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
@@ -73,11 +66,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-8">
|
||||
<p class="text-gray-600">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
<span class="ml-2"><a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a> © <?= date('Y') ?></span>
|
||||
<span class="ml-2"><a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a> © {{ "now"|date("Y") }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,10 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>500 - Internal Server Error</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<script>
|
||||
@@ -29,20 +26,17 @@
|
||||
<body class="bg-gradient-to-br from-red-50 to-orange-100 min-h-screen flex items-center justify-center p-6">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="bg-white rounded-2xl shadow-2xl p-12 text-center">
|
||||
<!-- Error Icon -->
|
||||
<div class="mb-8">
|
||||
<i class="fas fa-exclamation-circle text-red-500 text-8xl mb-4"></i>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<h1 class="text-9xl font-bold text-gray-800 mb-4">500</h1>
|
||||
<h2 class="text-3xl font-bold text-gray-700 mb-4">Internal Server Error</h2>
|
||||
<p class="text-gray-600 text-lg mb-8 leading-relaxed">
|
||||
Oops! Something went wrong on our end. We're working to fix the issue.
|
||||
</p>
|
||||
|
||||
<!-- Error Reference ID -->
|
||||
<?php if (!empty($error_id)): ?>
|
||||
{% if error_id is defined and error_id %}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-5 mb-8">
|
||||
<div class="flex items-center justify-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -52,7 +46,7 @@
|
||||
<p class="text-sm font-medium text-gray-700 mb-1">Error Reference ID:</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="text-lg font-mono font-bold text-primary bg-white px-3 py-1 rounded border border-blue-200">
|
||||
<?= htmlspecialchars($error_id) ?>
|
||||
{{ error_id }}
|
||||
</code>
|
||||
<button onclick="copyErrorId()"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
@@ -67,9 +61,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/" class="inline-flex items-center justify-center px-8 py-4 bg-primary text-white rounded-lg hover:bg-primary-dark transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
|
||||
<i class="fas fa-home mr-2"></i>
|
||||
@@ -85,7 +78,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Helpful Links -->
|
||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
||||
<p class="text-sm text-gray-500 mb-4">Quick Links:</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
@@ -101,41 +93,39 @@
|
||||
<i class="fas fa-search mr-1"></i>
|
||||
WHOIS Lookup
|
||||
</a>
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
{% if auth is defined and auth.isAdmin|default(false) %}
|
||||
<a href="/settings" class="text-primary hover:text-primary-dark transition-colors duration-150">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Settings
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support Info -->
|
||||
<div class="mt-8 bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600">
|
||||
<i class="fas fa-life-ring text-primary mr-1"></i>
|
||||
If this problem persists, please contact your system administrator
|
||||
<?php if (!empty($error_id)): ?>
|
||||
{% if error_id is defined and error_id %}
|
||||
and provide the error reference ID above.
|
||||
<?php else: ?>
|
||||
{% else %}
|
||||
.
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-8">
|
||||
<p class="text-gray-600">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
<span class="ml-2">Domain Monitor © <?= date('Y') ?></span>
|
||||
<span class="ml-2">Domain Monitor © {{ "now"|date("Y") }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyErrorId() {
|
||||
const errorId = '<?= htmlspecialchars($error_id ?? '') ?>';
|
||||
const errorId = {{ (error_id|default(''))|json_encode|raw }};
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(errorId).then(() => {
|
||||
@@ -184,4 +174,3 @@
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,572 +0,0 @@
|
||||
<?php
|
||||
$title = 'Error Details';
|
||||
$pageTitle = 'Error Details';
|
||||
$pageDescription = 'Detailed information about this error';
|
||||
$pageIcon = 'fas fa-bug';
|
||||
ob_start();
|
||||
|
||||
$isResolved = (bool)$error['is_resolved'];
|
||||
$errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['error_type'];
|
||||
?>
|
||||
|
||||
<!-- Back Navigation -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<a href="/errors" class="inline-flex items-center px-3 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Error Logs
|
||||
</a>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="copyErrorReport()" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-clipboard mr-2"></i>
|
||||
Copy Error Report
|
||||
</button>
|
||||
<?php if ($isResolved): ?>
|
||||
<form method="POST" action="/errors/<?= htmlspecialchars($error['error_id']) ?>/unresolve" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-undo mr-2"></i>
|
||||
Mark as Unresolved
|
||||
</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<button onclick="markResolved()" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Mark as Resolved
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<button onclick="deleteError()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Error
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Header Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 h-14 w-14 bg-red-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-bug text-red-600 text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="text-2xl font-semibold text-gray-900"><?= htmlspecialchars($errorTypeShort) ?></h2>
|
||||
<?php if ($isResolved): ?>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
Resolved
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800 border border-orange-200">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Unresolved
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-3"><?= htmlspecialchars($error['error_message']) ?></p>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-hashtag mr-1.5"></i>
|
||||
<span class="font-mono font-semibold text-primary"><?= htmlspecialchars($error['error_id']) ?></span>
|
||||
<button onclick="copyToClipboard('<?= htmlspecialchars($error['error_id']) ?>')" class="ml-2 text-gray-400 hover:text-primary" title="Copy Error ID">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-redo mr-1.5"></i>
|
||||
<span><?= $error['occurrences'] ?? 1 ?> occurrence<?= ($error['occurrences'] ?? 1) != 1 ? 's' : '' ?></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-1.5"></i>
|
||||
<span>Last: <?= date('M d, Y H:i:s', strtotime($error['last_occurred_at'] ?? $error['occurred_at'])) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Info -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">File</p>
|
||||
<p class="font-mono text-sm text-gray-900 break-all"><?= htmlspecialchars($error['error_file']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Line</p>
|
||||
<p class="font-mono text-sm text-gray-900"><?= $error['error_line'] ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Info (if resolved) -->
|
||||
<?php if ($isResolved && $error['resolved_at']): ?>
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-600 mt-0.5 mr-3"></i>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-green-900 mb-2">Resolved</h3>
|
||||
<div class="text-sm text-green-800 space-y-1">
|
||||
<p><strong>Date:</strong> <?= date('M d, Y H:i:s', strtotime($error['resolved_at'])) ?></p>
|
||||
<?php if (!empty($error['notes'])): ?>
|
||||
<p><strong>Notes:</strong> <?= htmlspecialchars($error['notes']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex">
|
||||
<button onclick="switchTab('stack-trace')" id="tab-stack-trace" class="tab-button active px-6 py-3 text-sm font-medium border-b-2 border-primary text-primary">
|
||||
<i class="fas fa-layer-group mr-2"></i>
|
||||
Stack Trace
|
||||
</button>
|
||||
<button onclick="switchTab('request')" id="tab-request" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>
|
||||
Request Data
|
||||
</button>
|
||||
<button onclick="switchTab('session')" id="tab-session" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
<i class="fas fa-user mr-2"></i>
|
||||
Session Data
|
||||
</button>
|
||||
<button onclick="switchTab('occurrences')" id="tab-occurrences" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
<i class="fas fa-history mr-2"></i>
|
||||
Occurrence Details (<?= $error['occurrences'] ?? 1 ?>)
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="p-6">
|
||||
<!-- Stack Trace Tab -->
|
||||
<div id="content-stack-trace" class="tab-content">
|
||||
<?php if (!empty($error['stack_trace_array'])): ?>
|
||||
<div class="space-y-2">
|
||||
<?php foreach ($error['stack_trace_array'] as $index => $trace): ?>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 hover:border-primary transition-colors">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center font-semibold text-sm mr-3">
|
||||
<?= $index ?>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<?php if (isset($trace['file'])): ?>
|
||||
<p class="font-mono text-xs text-gray-600 break-all mb-1">
|
||||
<?= htmlspecialchars($trace['file']) ?>
|
||||
<span class="text-primary font-semibold">line <?= $trace['line'] ?? '?' ?></span>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($trace['function'])): ?>
|
||||
<p class="font-mono text-sm text-gray-900">
|
||||
<?php if (isset($trace['class'])): ?>
|
||||
<span class="text-blue-600"><?= htmlspecialchars($trace['class']) ?></span><?= htmlspecialchars($trace['type']) ?>
|
||||
<?php endif; ?>
|
||||
<span class="text-indigo-600"><?= htmlspecialchars($trace['function']) ?></span>()
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="text-gray-500 text-center py-8">No stack trace available</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Request Data Tab -->
|
||||
<div id="content-request" class="tab-content hidden">
|
||||
<?php if (!empty($error['request_data'])): ?>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Request Info</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4 font-mono text-xs">
|
||||
<p><strong>Method:</strong> <?= htmlspecialchars($error['request_method']) ?></p>
|
||||
<p><strong>URI:</strong> <?= htmlspecialchars($error['request_uri']) ?></p>
|
||||
<p><strong>IP:</strong> <?= htmlspecialchars($error['ip_address']) ?></p>
|
||||
<p><strong>User Agent:</strong> <?= htmlspecialchars($error['user_agent']) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php foreach ($error['request_data'] as $key => $value): ?>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2"><?= htmlspecialchars(strtoupper($key)) ?></h3>
|
||||
<pre class="bg-gray-900 text-green-400 p-4 rounded-lg overflow-x-auto text-xs"><?= htmlspecialchars(json_encode($value, JSON_PRETTY_PRINT)) ?></pre>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="text-gray-500 text-center py-8">No request data available</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Session Data Tab -->
|
||||
<div id="content-session" class="tab-content hidden">
|
||||
<?php if (!empty($error['session_data'])): ?>
|
||||
<pre class="bg-gray-900 text-green-400 p-4 rounded-lg overflow-x-auto text-xs"><?= htmlspecialchars(json_encode($error['session_data'], JSON_PRETTY_PRINT)) ?></pre>
|
||||
<?php else: ?>
|
||||
<p class="text-gray-500 text-center py-8">No session data available</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Occurrences Tab -->
|
||||
<div id="content-occurrences" class="tab-content hidden">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-blue-600 mt-0.5 mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-blue-900 mb-1">Error Occurrence Tracking</p>
|
||||
<p class="text-sm text-blue-800">
|
||||
This error has occurred <strong><?= $error['occurrences'] ?? 1 ?> time<?= ($error['occurrences'] ?? 1) != 1 ? 's' : '' ?></strong>.
|
||||
Similar errors are automatically grouped together and the occurrence count is incremented.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Occurrence Information</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">First Occurred</p>
|
||||
<p class="text-sm text-gray-900"><?= date('M d, Y H:i:s', strtotime($error['occurred_at'])) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Last Occurred</p>
|
||||
<p class="text-sm text-gray-900"><?= date('M d, Y H:i:s', strtotime($error['last_occurred_at'] ?? $error['occurred_at'])) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Total Occurrences</p>
|
||||
<p class="text-sm text-gray-900"><?= $error['occurrences'] ?? 1 ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Request Details</p>
|
||||
<p class="text-xs text-gray-600">
|
||||
<?= htmlspecialchars($error['request_method']) ?>
|
||||
<?= htmlspecialchars($error['request_uri']) ?>
|
||||
</p>
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
IP: <?= htmlspecialchars($error['ip_address']) ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">System Information</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">PHP Version</p>
|
||||
<p class="text-sm text-gray-900"><?= htmlspecialchars($error['php_version']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Memory Usage</p>
|
||||
<p class="text-sm text-gray-900"><?= htmlspecialchars($error['memory_usage']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">First Occurred</p>
|
||||
<p class="text-sm text-gray-900"><?= date('M d, Y H:i:s', strtotime($error['occurred_at'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Notes Modal -->
|
||||
<div id="resolutionModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-full max-w-md shadow-lg rounded-lg bg-white">
|
||||
<div class="mt-3">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
<i class="fas fa-check-circle text-green-600 mr-2"></i>
|
||||
Mark Error as Resolved
|
||||
</h3>
|
||||
<button onclick="closeResolutionModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="mb-4">
|
||||
<label for="resolutionNotes" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Resolution Notes (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="resolutionNotes"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
|
||||
placeholder="Describe how you resolved this error or any relevant notes..."
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Add any details about the fix or resolution for future reference.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onclick="closeResolutionModal()"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick="submitResolution()"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Mark as Resolved
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('active', 'border-primary', 'text-primary');
|
||||
button.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
document.getElementById('content-' + tabName).classList.remove('hidden');
|
||||
|
||||
// Add active class to selected tab
|
||||
const activeTab = document.getElementById('tab-' + tabName);
|
||||
activeTab.classList.add('active', 'border-primary', 'text-primary');
|
||||
activeTab.classList.remove('border-transparent', 'text-gray-500');
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopySuccess();
|
||||
}).catch(() => {
|
||||
fallbackCopy(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopySuccess();
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function copyErrorReport() {
|
||||
const errorType = <?= json_encode($error['error_type'] ?? 'Error') ?>;
|
||||
const errorMessage = <?= json_encode($error['error_message'] ?? 'Unknown error') ?>;
|
||||
const errorFile = <?= json_encode($error['error_file'] ?? 'Unknown') ?>;
|
||||
const errorLine = <?= json_encode($error['error_line'] ?? '?') ?>;
|
||||
const errorId = <?= json_encode($error['error_id'] ?? 'N/A') ?>;
|
||||
const phpVersion = <?= json_encode($error['php_version'] ?? 'Unknown') ?>;
|
||||
const memoryUsage = <?= json_encode($error['memory_usage'] ?? 'Unknown') ?>;
|
||||
const requestMethod = <?= json_encode($error['request_method'] ?? 'GET') ?>;
|
||||
const requestUri = <?= json_encode($error['request_uri'] ?? '/') ?>;
|
||||
const userAgent = <?= json_encode($error['user_agent'] ?? 'Unknown') ?>;
|
||||
const ipAddress = <?= json_encode($error['ip_address'] ?? 'Unknown') ?>;
|
||||
const occurredAt = <?= json_encode(date('Y-m-d H:i:s', strtotime($error['occurred_at']))) ?>;
|
||||
const lastOccurredAt = <?= json_encode(date('Y-m-d H:i:s', strtotime($error['last_occurred_at'] ?? $error['occurred_at']))) ?>;
|
||||
const occurrences = <?= json_encode($error['occurrences'] ?? 1) ?>;
|
||||
const isResolved = <?= json_encode($isResolved) ?>;
|
||||
const requestData = <?= json_encode($error['request_data'] ?? null) ?>;
|
||||
const sessionData = <?= json_encode($error['session_data'] ?? null) ?>;
|
||||
|
||||
// Get stack trace from the rendered elements
|
||||
const traceFrames = document.querySelectorAll('#content-stack-trace .bg-gray-50');
|
||||
let stackTrace = 'Not available';
|
||||
if (traceFrames.length > 0) {
|
||||
let traceLines = [];
|
||||
traceFrames.forEach((frame, i) => {
|
||||
const fileLine = frame.querySelector('.font-mono.text-xs');
|
||||
const funcLine = frame.querySelector('.font-mono.text-sm');
|
||||
let line = '#' + i + ' ';
|
||||
if (fileLine) line += fileLine.textContent.trim().replace(/\s+/g, ' ');
|
||||
if (funcLine) line += ' ' + funcLine.textContent.trim().replace(/\s+/g, '');
|
||||
traceLines.push(line);
|
||||
});
|
||||
stackTrace = traceLines.join('\n');
|
||||
}
|
||||
|
||||
// Format request data sections
|
||||
let requestDataText = 'Not available';
|
||||
if (requestData && typeof requestData === 'object' && Object.keys(requestData).length > 0) {
|
||||
let sections = [];
|
||||
for (const [key, value] of Object.entries(requestData)) {
|
||||
sections.push(` [${key.toUpperCase()}]\n ${JSON.stringify(value, null, 2).split('\n').join('\n ')}`);
|
||||
}
|
||||
requestDataText = sections.join('\n\n');
|
||||
}
|
||||
|
||||
// Format session data
|
||||
let sessionDataText = 'Not available';
|
||||
if (sessionData && typeof sessionData === 'object' && Object.keys(sessionData).length > 0) {
|
||||
sessionDataText = ' ' + JSON.stringify(sessionData, null, 2).split('\n').join('\n ');
|
||||
}
|
||||
|
||||
const errorReport = `=== DOMAIN MONITOR ERROR REPORT ===
|
||||
|
||||
ERROR INFORMATION:
|
||||
- Error ID: ${errorId}
|
||||
- Type: ${errorType}
|
||||
- Message: ${errorMessage}
|
||||
- Status: ${isResolved ? 'Resolved' : 'Unresolved'}
|
||||
- Occurrences: ${occurrences}
|
||||
|
||||
LOCATION:
|
||||
- File: ${errorFile}
|
||||
- Line: ${errorLine}
|
||||
|
||||
REQUEST DETAILS:
|
||||
- Method: ${requestMethod}
|
||||
- URI: ${requestUri}
|
||||
- IP Address: ${ipAddress}
|
||||
- User Agent: ${userAgent}
|
||||
- First Occurred: ${occurredAt}
|
||||
- Last Occurred: ${lastOccurredAt}
|
||||
|
||||
REQUEST DATA:
|
||||
${requestDataText}
|
||||
|
||||
SESSION DATA:
|
||||
${sessionDataText}
|
||||
|
||||
SYSTEM INFORMATION:
|
||||
- PHP Version: ${phpVersion}
|
||||
- Memory Usage: ${memoryUsage}
|
||||
|
||||
STACK TRACE:
|
||||
${stackTrace}
|
||||
|
||||
=== END OF ERROR REPORT ===
|
||||
|
||||
Reference ID: ${errorId}
|
||||
Please include this report when reporting bugs.`;
|
||||
|
||||
copyToClipboard(errorReport);
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
// Use the existing toast container from messages.php
|
||||
let container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'fixed bottom-4 right-4 z-[9999] space-y-3 max-w-sm';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast bg-white border-l-4 border-green-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in';
|
||||
toast.innerHTML = `
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-check text-green-600 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">Success</p>
|
||||
<p class="text-sm text-gray-600 mt-0.5">Copied to clipboard!</p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function markResolved() {
|
||||
document.getElementById('resolutionModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeResolutionModal() {
|
||||
document.getElementById('resolutionModal').classList.add('hidden');
|
||||
document.getElementById('resolutionNotes').value = '';
|
||||
}
|
||||
|
||||
function submitResolution() {
|
||||
const notes = document.getElementById('resolutionNotes').value;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/<?= htmlspecialchars($error['error_id']) ?>/resolve';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
if (notes) {
|
||||
const notesInput = document.createElement('input');
|
||||
notesInput.type = 'hidden';
|
||||
notesInput.name = 'notes';
|
||||
notesInput.value = notes;
|
||||
form.appendChild(notesInput);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError() {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/<?= htmlspecialchars($error['error_id']) ?>/delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
|
||||
482
app/Views/errors/admin-detail.twig
Normal file
482
app/Views/errors/admin-detail.twig
Normal file
@@ -0,0 +1,482 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Error Details' %}
|
||||
{% set pageTitle = 'Error Details' %}
|
||||
{% set pageDescription = 'Detailed information about this error' %}
|
||||
{% set pageIcon = 'fas fa-bug' %}
|
||||
|
||||
{% set isResolved = error.is_resolved %}
|
||||
{% set errorTypeShort = error.error_type|split('\\')|last %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Back Navigation -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<a href="/errors" class="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Error Logs
|
||||
</a>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="copyErrorReport()" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-clipboard mr-2"></i>
|
||||
Copy Error Report
|
||||
</button>
|
||||
{% if isResolved %}
|
||||
<form method="POST" action="/errors/{{ error.error_id }}/unresolve" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-undo mr-2"></i>
|
||||
Mark as Unresolved
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button onclick="markResolved()" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Mark as Resolved
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button onclick="deleteError()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Error
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Header Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-6 mb-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 h-14 w-14 bg-red-100 dark:bg-red-500/10 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-bug text-red-600 dark:text-red-400 text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ errorTypeShort }}</h2>
|
||||
{% if isResolved %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 border border-green-200 dark:border-green-500/20">
|
||||
<i class="fas fa-check-circle mr-1"></i>Resolved
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 border border-orange-200 dark:border-orange-500/20">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>Unresolved
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-gray-600 dark:text-slate-400 mb-3">{{ error.error_message }}</p>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-slate-400">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-hashtag mr-1.5"></i>
|
||||
<span class="font-mono font-semibold text-primary">{{ error.error_id }}</span>
|
||||
<button onclick="copyToClipboard('{{ error.error_id }}')" class="ml-2 text-gray-400 dark:text-slate-500 hover:text-primary" title="Copy Error ID">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-redo mr-1.5"></i>
|
||||
<span>{{ error.occurrences|default(1) }} occurrence{{ error.occurrences|default(1) != 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-1.5"></i>
|
||||
<span>Last: {{ (error.last_occurred_at|default(error.occurred_at))|date("M d, Y H:i:s") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide mb-1">File</p>
|
||||
<p class="font-mono text-sm text-gray-900 dark:text-white break-all">{{ error.error_file }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide mb-1">Line</p>
|
||||
<p class="font-mono text-sm text-gray-900 dark:text-white">{{ error.error_line }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if isResolved and error.resolved_at %}
|
||||
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/20 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-0.5 mr-3"></i>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-green-900 dark:text-green-400 mb-2">Resolved</h3>
|
||||
<div class="text-sm text-green-800 dark:text-green-300 space-y-1">
|
||||
<p><strong>Date:</strong> {{ error.resolved_at|date("M d, Y H:i:s") }}</p>
|
||||
{% if error.notes %}
|
||||
<p><strong>Notes:</strong> {{ error.notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden mb-6">
|
||||
<div class="border-b border-gray-200 dark:border-slate-700">
|
||||
<nav class="-mb-px flex">
|
||||
<button onclick="switchTab('stack-trace')" id="tab-stack-trace" class="tab-button active px-6 py-3 text-sm font-medium border-b-2 border-primary text-primary">
|
||||
<i class="fas fa-layer-group mr-2"></i>Stack Trace
|
||||
</button>
|
||||
<button onclick="switchTab('request')" id="tab-request" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 hover:border-gray-300 dark:hover:border-slate-600">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>Request Data
|
||||
</button>
|
||||
<button onclick="switchTab('session')" id="tab-session" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 hover:border-gray-300 dark:hover:border-slate-600">
|
||||
<i class="fas fa-user mr-2"></i>Session Data
|
||||
</button>
|
||||
<button onclick="switchTab('occurrences')" id="tab-occurrences" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300 hover:border-gray-300 dark:hover:border-slate-600">
|
||||
<i class="fas fa-history mr-2"></i>Occurrence Details ({{ error.occurrences|default(1) }})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Stack Trace Tab -->
|
||||
<div id="content-stack-trace" class="tab-content">
|
||||
{% if error.stack_trace_array is defined and error.stack_trace_array %}
|
||||
<div class="space-y-2">
|
||||
{% for index, trace in error.stack_trace_array %}
|
||||
<div class="bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg p-4 hover:border-primary transition-colors">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center font-semibold text-sm mr-3">
|
||||
{{ index }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if trace.file is defined %}
|
||||
<p class="font-mono text-xs text-gray-600 dark:text-slate-400 break-all mb-1">
|
||||
{{ trace.file }}
|
||||
<span class="text-primary font-semibold">line {{ trace.line|default('?') }}</span>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if trace.function is defined %}
|
||||
<p class="font-mono text-sm text-gray-900 dark:text-white">
|
||||
{% if trace.class is defined %}
|
||||
<span class="text-blue-600 dark:text-blue-400">{{ trace.class }}</span>{{ trace.type }}
|
||||
{% endif %}
|
||||
<span class="text-indigo-600 dark:text-indigo-400">{{ trace.function }}</span>()
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-slate-400 text-center py-8">No stack trace available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Request Data Tab -->
|
||||
<div id="content-request" class="tab-content hidden">
|
||||
{% if error.request_data is defined and error.request_data %}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-slate-300 mb-2">Request Info</h3>
|
||||
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4 font-mono text-xs">
|
||||
<p><strong>Method:</strong> {{ error.request_method }}</p>
|
||||
<p><strong>URI:</strong> {{ error.request_uri }}</p>
|
||||
<p><strong>IP:</strong> {{ error.ip_address }}</p>
|
||||
<p><strong>User Agent:</strong> {{ error.user_agent }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% for key, value in error.request_data %}
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-slate-300 mb-2">{{ key|upper }}</h3>
|
||||
<pre class="bg-gray-900 text-green-400 p-4 rounded-lg overflow-x-auto text-xs">{{ value|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-slate-400 text-center py-8">No request data available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Session Data Tab -->
|
||||
<div id="content-session" class="tab-content hidden">
|
||||
{% if error.session_data is defined and error.session_data %}
|
||||
<pre class="bg-gray-900 text-green-400 p-4 rounded-lg overflow-x-auto text-xs">{{ error.session_data|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-slate-400 text-center py-8">No session data available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Occurrences Tab -->
|
||||
<div id="content-occurrences" class="tab-content hidden">
|
||||
<div class="space-y-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mt-0.5 mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-blue-900 dark:text-blue-400 mb-1">Error Occurrence Tracking</p>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
This error has occurred <strong>{{ error.occurrences|default(1) }} time{{ error.occurrences|default(1) != 1 ? 's' : '' }}</strong>.
|
||||
Similar errors are automatically grouped together and the occurrence count is incremented.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">Occurrence Information</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide mb-1">First Occurred</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{{ error.occurred_at|date("M d, Y H:i:s") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide mb-1">Last Occurred</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{{ (error.last_occurred_at|default(error.occurred_at))|date("M d, Y H:i:s") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide mb-1">Total Occurrences</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{{ error.occurrences|default(1) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide mb-1">Request Details</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">{{ error.request_method }} {{ error.request_uri }}</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">IP: {{ error.ip_address }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">System Information</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide mb-1">PHP Version</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{{ error.php_version }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide mb-1">Memory Usage</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{{ error.memory_usage }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide mb-1">First Occurred</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{{ error.occurred_at|date("M d, Y H:i:s") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Notes Modal -->
|
||||
<div id="resolutionModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-full max-w-md shadow-lg rounded-lg bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
<i class="fas fa-check-circle text-green-600 mr-2"></i>Mark Error as Resolved
|
||||
</h3>
|
||||
<button onclick="closeResolutionModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="resolutionNotes" class="block text-sm font-medium text-gray-700 mb-2">Resolution Notes (Optional)</label>
|
||||
<textarea id="resolutionNotes" rows="4"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
|
||||
placeholder="Describe how you resolved this error or any relevant notes..."></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">Add any details about the fix or resolution for future reference.</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button onclick="closeResolutionModal()" class="px-4 py-2 bg-gray-200 dark:bg-slate-700 text-gray-800 dark:text-slate-200 rounded-lg hover:bg-gray-300 dark:hover:bg-slate-600 transition-colors text-sm font-medium">Cancel</button>
|
||||
<button onclick="submitResolution()" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-check mr-2"></i>Mark as Resolved
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab-content').forEach(content => { content.classList.add('hidden'); });
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('active', 'border-primary', 'text-primary');
|
||||
button.classList.add('border-transparent', 'text-gray-500', 'dark:text-slate-400');
|
||||
});
|
||||
document.getElementById('content-' + tabName).classList.remove('hidden');
|
||||
const activeTab = document.getElementById('tab-' + tabName);
|
||||
activeTab.classList.add('active', 'border-primary', 'text-primary');
|
||||
activeTab.classList.remove('border-transparent', 'text-gray-500', 'dark:text-slate-400');
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => { showCopySuccess(); }).catch(() => { fallbackCopy(text); });
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try { document.execCommand('copy'); showCopySuccess(); } catch (err) { console.error('Copy failed:', err); }
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function copyErrorReport() {
|
||||
const errorType = {{ error.error_type|default('Error')|json_encode|raw }};
|
||||
const errorMessage = {{ error.error_message|default('Unknown error')|json_encode|raw }};
|
||||
const errorFile = {{ error.error_file|default('Unknown')|json_encode|raw }};
|
||||
const errorLine = {{ error.error_line|default('?')|json_encode|raw }};
|
||||
const errorId = {{ error.error_id|default('N/A')|json_encode|raw }};
|
||||
const phpVersion = {{ error.php_version|default('Unknown')|json_encode|raw }};
|
||||
const memoryUsage = {{ error.memory_usage|default('Unknown')|json_encode|raw }};
|
||||
const requestMethod = {{ error.request_method|default('GET')|json_encode|raw }};
|
||||
const requestUri = {{ error.request_uri|default('/')|json_encode|raw }};
|
||||
const userAgent = {{ error.user_agent|default('Unknown')|json_encode|raw }};
|
||||
const ipAddress = {{ error.ip_address|default('Unknown')|json_encode|raw }};
|
||||
const occurredAt = {{ error.occurred_at|date("Y-m-d H:i:s")|json_encode|raw }};
|
||||
const lastOccurredAt = {{ (error.last_occurred_at|default(error.occurred_at))|date("Y-m-d H:i:s")|json_encode|raw }};
|
||||
const occurrences = {{ error.occurrences|default(1)|json_encode|raw }};
|
||||
const isResolved = {{ isResolved|json_encode|raw }};
|
||||
const requestData = {{ error.request_data|default(null)|json_encode|raw }};
|
||||
const sessionData = {{ error.session_data|default(null)|json_encode|raw }};
|
||||
|
||||
const traceFrames = document.querySelectorAll('#content-stack-trace .bg-gray-50');
|
||||
let stackTrace = 'Not available';
|
||||
if (traceFrames.length > 0) {
|
||||
let traceLines = [];
|
||||
traceFrames.forEach((frame, i) => {
|
||||
const fileLine = frame.querySelector('.font-mono.text-xs');
|
||||
const funcLine = frame.querySelector('.font-mono.text-sm');
|
||||
let line = '#' + i + ' ';
|
||||
if (fileLine) line += fileLine.textContent.trim().replace(/\s+/g, ' ');
|
||||
if (funcLine) line += ' ' + funcLine.textContent.trim().replace(/\s+/g, '');
|
||||
traceLines.push(line);
|
||||
});
|
||||
stackTrace = traceLines.join('\n');
|
||||
}
|
||||
|
||||
let requestDataText = 'Not available';
|
||||
if (requestData && typeof requestData === 'object' && Object.keys(requestData).length > 0) {
|
||||
let sections = [];
|
||||
for (const [key, value] of Object.entries(requestData)) {
|
||||
sections.push(` [${key.toUpperCase()}]\n ${JSON.stringify(value, null, 2).split('\n').join('\n ')}`);
|
||||
}
|
||||
requestDataText = sections.join('\n\n');
|
||||
}
|
||||
|
||||
let sessionDataText = 'Not available';
|
||||
if (sessionData && typeof sessionData === 'object' && Object.keys(sessionData).length > 0) {
|
||||
sessionDataText = ' ' + JSON.stringify(sessionData, null, 2).split('\n').join('\n ');
|
||||
}
|
||||
|
||||
const errorReport = `=== DOMAIN MONITOR ERROR REPORT ===
|
||||
|
||||
ERROR INFORMATION:
|
||||
- Error ID: ${errorId}
|
||||
- Type: ${errorType}
|
||||
- Message: ${errorMessage}
|
||||
- Status: ${isResolved ? 'Resolved' : 'Unresolved'}
|
||||
- Occurrences: ${occurrences}
|
||||
|
||||
LOCATION:
|
||||
- File: ${errorFile}
|
||||
- Line: ${errorLine}
|
||||
|
||||
REQUEST DETAILS:
|
||||
- Method: ${requestMethod}
|
||||
- URI: ${requestUri}
|
||||
- IP Address: ${ipAddress}
|
||||
- User Agent: ${userAgent}
|
||||
- First Occurred: ${occurredAt}
|
||||
- Last Occurred: ${lastOccurredAt}
|
||||
|
||||
REQUEST DATA:
|
||||
${requestDataText}
|
||||
|
||||
SESSION DATA:
|
||||
${sessionDataText}
|
||||
|
||||
SYSTEM INFORMATION:
|
||||
- PHP Version: ${phpVersion}
|
||||
- Memory Usage: ${memoryUsage}
|
||||
|
||||
STACK TRACE:
|
||||
${stackTrace}
|
||||
|
||||
=== END OF ERROR REPORT ===
|
||||
|
||||
Reference ID: ${errorId}
|
||||
Please include this report when reporting bugs.`;
|
||||
|
||||
copyToClipboard(errorReport);
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
let container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'fixed bottom-4 right-4 z-[9999] space-y-3 max-w-sm';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast bg-white dark:bg-slate-800 border-l-4 border-green-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in';
|
||||
toast.innerHTML = '<div class="flex-shrink-0"><div class="w-8 h-8 bg-green-100 dark:bg-green-500/10 rounded-full flex items-center justify-center"><i class="fas fa-check text-green-600 dark:text-green-400 text-sm"></i></div></div><div class="ml-3 flex-1"><p class="text-sm font-medium text-gray-900 dark:text-white">Success</p><p class="text-sm text-gray-600 dark:text-slate-400 mt-0.5">Copied to clipboard!</p></div><button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300 transition-colors"><i class="fas fa-times text-sm"></i></button>';
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => { toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; toast.style.opacity = '0'; toast.style.transform = 'translateX(100%)'; setTimeout(() => toast.remove(), 300); }, 3000);
|
||||
}
|
||||
|
||||
function markResolved() {
|
||||
document.getElementById('resolutionModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeResolutionModal() {
|
||||
document.getElementById('resolutionModal').classList.add('hidden');
|
||||
document.getElementById('resolutionNotes').value = '';
|
||||
}
|
||||
|
||||
function submitResolution() {
|
||||
const notes = document.getElementById('resolutionNotes').value;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/{{ error.error_id }}/resolve';
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
if (notes) {
|
||||
const notesInput = document.createElement('input');
|
||||
notesInput.type = 'hidden';
|
||||
notesInput.name = 'notes';
|
||||
notesInput.value = notes;
|
||||
form.appendChild(notesInput);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError() {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/{{ error.error_id }}/delete';
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,605 +0,0 @@
|
||||
<?php
|
||||
$title = 'Error Logs';
|
||||
$pageTitle = 'Error Logs';
|
||||
$pageDescription = 'Monitor and manage application errors';
|
||||
$pageIcon = 'fas fa-bug';
|
||||
ob_start();
|
||||
|
||||
// Helper function to generate sort URL
|
||||
function sortUrl($column, $currentSort, $currentOrder, $filters) {
|
||||
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
|
||||
$params = $filters;
|
||||
$params['sort'] = $column;
|
||||
$params['order'] = $newOrder;
|
||||
return '/errors?' . http_build_query($params);
|
||||
}
|
||||
|
||||
// Helper function for sort icon
|
||||
function sortIcon($column, $currentSort, $currentOrder) {
|
||||
if ($currentSort !== $column) {
|
||||
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
|
||||
}
|
||||
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
|
||||
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
|
||||
}
|
||||
|
||||
// Get current filters
|
||||
$currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_occurred_at', 'order' => 'desc'];
|
||||
?>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Total Errors Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Errors</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $errorStats['total_errors'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-red-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unresolved Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Unresolved</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $errorStats['unresolved'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-circle text-orange-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last 24h Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Last 24h</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $errorStats['last_24h'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-clock text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Occurrences Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Occurrences</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $errorStats['total_occurrences'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-indigo-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-layer-group text-indigo-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<form method="GET" action="/errors" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
|
||||
<select name="resolved" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">All Errors</option>
|
||||
<option value="0" <?= $currentFilters['resolved'] === '0' ? 'selected' : '' ?>>Unresolved Only</option>
|
||||
<option value="1" <?= $currentFilters['resolved'] === '1' ? 'selected' : '' ?>>Resolved Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Error Type</label>
|
||||
<input type="text" name="type" value="<?= htmlspecialchars($currentFilters['type']) ?>" placeholder="e.g., PDOException" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Sort By</label>
|
||||
<select name="sort" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="last_occurred_at" <?= $currentFilters['sort'] === 'last_occurred_at' ? 'selected' : '' ?>>Last Occurred</option>
|
||||
<option value="occurrences" <?= $currentFilters['sort'] === 'occurrences' ? 'selected' : '' ?>>Most Frequent</option>
|
||||
<option value="occurred_at" <?= $currentFilters['sort'] === 'occurred_at' ? 'selected' : '' ?>>First Occurred</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply
|
||||
</button>
|
||||
<a href="/errors" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?></span> error(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/errors" class="flex items-center gap-2">
|
||||
<!-- Preserve filters -->
|
||||
<input type="hidden" name="resolved" value="<?= htmlspecialchars($currentFilters['resolved']) ?>">
|
||||
<input type="hidden" name="type" value="<?= htmlspecialchars($currentFilters['type']) ?>">
|
||||
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="10" <?= $pagination['per_page'] == 10 ? 'selected' : '' ?>>10</option>
|
||||
<option value="25" <?= $pagination['per_page'] == 25 ? 'selected' : '' ?>>25</option>
|
||||
<option value="50" <?= $pagination['per_page'] == 50 ? 'selected' : '' ?>>50</option>
|
||||
<option value="100" <?= $pagination['per_page'] == 100 ? 'selected' : '' ?>>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Errors List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when errors are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 text-primary focus:ring-primary">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Error
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Occurrences
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Last Occurred
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<?php
|
||||
$errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['error_type'];
|
||||
$isResolved = (bool)$error['is_resolved'];
|
||||
?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
<input type="checkbox" class="error-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= htmlspecialchars($error['error_id']) ?>" onchange="updateBulkActions()">
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-red-100 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-bug text-red-600"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-mono font-semibold text-primary"><?= htmlspecialchars($error['error_id']) ?></span>
|
||||
<button onclick="copyToClipboard('<?= htmlspecialchars($error['error_id']) ?>')" class="text-gray-400 hover:text-primary" title="Copy Error ID">
|
||||
<i class="fas fa-copy text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($errorTypeShort) ?></p>
|
||||
<p class="text-xs text-gray-600 mt-0.5 truncate" style="max-width: 300px;" title="<?= htmlspecialchars($error['error_message']) ?>">
|
||||
<?= htmlspecialchars($error['error_message']) ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-xs">
|
||||
<p class="font-mono text-gray-600 truncate" style="max-width: 200px;" title="<?= htmlspecialchars($error['error_file']) ?>">
|
||||
<?= htmlspecialchars(basename($error['error_file'])) ?>
|
||||
</p>
|
||||
<p class="text-gray-500 mt-0.5">
|
||||
<i class="fas fa-hashtag mr-1"></i>
|
||||
Line <?= $error['error_line'] ?>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold <?= $error['occurrences'] >= 10 ? 'bg-red-100 text-red-800' : 'bg-gray-100 text-gray-800' ?>">
|
||||
<i class="fas fa-redo mr-1"></i>
|
||||
<?= $error['occurrences'] ?>×
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
<?= date('M d, H:i', strtotime($error['last_occurred_at'])) ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<?php if ($isResolved): ?>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
Resolved
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800 border border-orange-200">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Unresolved
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/errors/<?= htmlspecialchars($error['error_id']) ?>" class="text-blue-600 hover:text-blue-800" title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<?php if (!$isResolved): ?>
|
||||
<button onclick="markResolved('<?= htmlspecialchars($error['error_id']) ?>')" class="text-green-600 hover:text-green-800" title="Mark as Resolved">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button onclick="deleteError('<?= htmlspecialchars($error['error_id']) ?>')" class="text-red-600 hover:text-red-800" title="Delete Error">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-check-circle text-green-500 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Errors Found</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
<?php if (!empty($currentFilters['resolved']) || !empty($currentFilters['type'])): ?>
|
||||
No errors match your filter criteria.
|
||||
<?php else: ?>
|
||||
Great! Your application is running smoothly.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<?php if ($pagination['total_pages'] > 1): ?>
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<?php
|
||||
$currentPage = $pagination['current_page'];
|
||||
$totalPages = $pagination['total_pages'];
|
||||
|
||||
function paginationUrl($page, $filters, $perPage) {
|
||||
$params = $filters;
|
||||
$params['page'] = $page;
|
||||
$params['per_page'] = $perPage;
|
||||
return '/errors?' . http_build_query($params);
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl(1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<a href="<?= paginationUrl($currentPage - 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$range = 2;
|
||||
$start = max(1, $currentPage - $range);
|
||||
$end = min($totalPages, $currentPage + $range);
|
||||
|
||||
if ($start > 1) {
|
||||
echo '<a href="' . paginationUrl(1, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">1</a>';
|
||||
if ($start > 2) echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
if ($i == $currentPage) {
|
||||
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
|
||||
} else {
|
||||
echo '<a href="' . paginationUrl($i, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">' . $i . '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
if ($end < $totalPages) {
|
||||
if ($end < $totalPages - 1) echo '<span class="px-2 text-gray-500">...</span>';
|
||||
echo '<a href="' . paginationUrl($totalPages, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">' . $totalPages . '</a>';
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($currentPage + 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<a href="<?= paginationUrl($totalPages, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Resolution Notes Modal -->
|
||||
<div id="resolutionModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-full max-w-md shadow-lg rounded-lg bg-white">
|
||||
<div class="mt-3">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
<i class="fas fa-check-circle text-green-600 mr-2"></i>
|
||||
Mark Error as Resolved
|
||||
</h3>
|
||||
<button onclick="closeResolutionModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="mb-4">
|
||||
<label for="resolutionNotes" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Resolution Notes (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="resolutionNotes"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
|
||||
placeholder="Describe how you resolved this error or any relevant notes..."
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Add any details about the fix or resolution for future reference.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
onclick="closeResolutionModal()"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick="submitResolution()"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Mark as Resolved
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopySuccess();
|
||||
});
|
||||
} else {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
showCopySuccess();
|
||||
}
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
// Use the existing toast container from messages.php
|
||||
let container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'fixed bottom-4 right-4 z-[9999] space-y-3 max-w-sm';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast bg-white border-l-4 border-green-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in';
|
||||
toast.innerHTML = `
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-check text-green-600 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">Success</p>
|
||||
<p class="text-sm text-gray-600 mt-0.5">Copied to clipboard!</p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
let currentErrorId = null;
|
||||
|
||||
function markResolved(errorId) {
|
||||
currentErrorId = errorId;
|
||||
document.getElementById('resolutionModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeResolutionModal() {
|
||||
document.getElementById('resolutionModal').classList.add('hidden');
|
||||
document.getElementById('resolutionNotes').value = '';
|
||||
currentErrorId = null;
|
||||
}
|
||||
|
||||
function submitResolution() {
|
||||
if (!currentErrorId) return;
|
||||
|
||||
const notes = document.getElementById('resolutionNotes').value;
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/' + currentErrorId + '/resolve';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
if (notes) {
|
||||
const notesInput = document.createElement('input');
|
||||
notesInput.type = 'hidden';
|
||||
notesInput.name = 'notes';
|
||||
notesInput.value = notes;
|
||||
form.appendChild(notesInput);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError(errorId) {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/' + errorId + '/delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
// Checkbox selection functions
|
||||
function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.error-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkboxes = document.querySelectorAll('.error-checkbox:checked');
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
selectedCount.textContent = checkboxes.length + ' error(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.error-checkbox');
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkboxes.length > 0 && checkboxes.length < allCheckboxes.length;
|
||||
}
|
||||
|
||||
function getSelectedErrorIds() {
|
||||
const checkboxes = document.querySelectorAll('.error-checkbox:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const checkboxes = document.querySelectorAll('.error-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
document.getElementById('select-all').checked = false;
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
const errorIds = getSelectedErrorIds();
|
||||
|
||||
if (errorIds.length === 0) {
|
||||
alert('Please select at least one error to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${errorIds.length} error(s) and all their occurrences? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/bulk-delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'error_ids';
|
||||
idsInput.value = JSON.stringify(errorIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
|
||||
481
app/Views/errors/admin-index.twig
Normal file
481
app/Views/errors/admin-index.twig
Normal file
@@ -0,0 +1,481 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Error Logs' %}
|
||||
{% set pageTitle = 'Error Logs' %}
|
||||
{% set pageDescription = 'Monitor and manage application errors' %}
|
||||
{% set pageIcon = 'fas fa-bug' %}
|
||||
|
||||
{% set currentFilters = filters|default({resolved: '', type: '', sort: 'last_occurred_at', order: 'desc'}) %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Total Errors</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ errorStats.total_errors|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-red-50 dark:bg-red-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Unresolved</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ errorStats.unresolved|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-50 dark:bg-orange-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-circle text-orange-600 dark:text-orange-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Last 24h</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ errorStats.last_24h|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-50 dark:bg-blue-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-clock text-blue-600 dark:text-blue-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Occurrences</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ errorStats.total_occurrences|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-indigo-50 dark:bg-indigo-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-layer-group text-indigo-600 dark:text-indigo-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 mb-4">
|
||||
<form method="GET" action="/errors" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Status</label>
|
||||
<select name="resolved" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="">All Errors</option>
|
||||
<option value="0" {{ currentFilters.resolved == '0' ? 'selected' : '' }}>Unresolved Only</option>
|
||||
<option value="1" {{ currentFilters.resolved == '1' ? 'selected' : '' }}>Resolved Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Error Type</label>
|
||||
<input type="text" name="type" value="{{ currentFilters.type }}" placeholder="e.g., PDOException" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Sort By</label>
|
||||
<select name="sort" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="last_occurred_at" {{ currentFilters.sort == 'last_occurred_at' ? 'selected' : '' }}>Last Occurred</option>
|
||||
<option value="occurrences" {{ currentFilters.sort == 'occurrences' ? 'selected' : '' }}>Most Frequent</option>
|
||||
<option value="occurred_at" {{ currentFilters.sort == 'occurred_at' ? 'selected' : '' }}>First Occurred</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply
|
||||
</button>
|
||||
<a href="/errors" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_from }}</span> to
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_to }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total }}</span> error(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/errors" class="flex items-center gap-2">
|
||||
<input type="hidden" name="resolved" value="{{ currentFilters.resolved }}">
|
||||
<input type="hidden" name="type" value="{{ currentFilters.type }}">
|
||||
<input type="hidden" name="sort" value="{{ currentFilters.sort }}">
|
||||
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600 dark:text-slate-400">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="10" {{ pagination.per_page == 10 ? 'selected' : '' }}>10</option>
|
||||
<option value="25" {{ pagination.per_page == 25 ? 'selected' : '' }}>25</option>
|
||||
<option value="50" {{ pagination.per_page == 50 ? 'selected' : '' }}>50</option>
|
||||
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Errors List -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 dark:bg-blue-500/10 border-b border-blue-200 dark:border-blue-500/20 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700 dark:text-slate-300"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-slate-400 hover:text-gray-900 dark:hover:text-white hover:bg-blue-100 dark:hover:bg-blue-500/20 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
{% if errors is defined and errors is not empty %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Error</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Location</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Occurrences</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Last Occurred</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for error in errors %}
|
||||
{% set errorTypeShort = error.error_type|split('\\')|last %}
|
||||
{% set isResolved = error.is_resolved %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
<input type="checkbox" class="error-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="{{ error.error_id }}" onchange="updateBulkActions()">
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-red-100 dark:bg-red-500/10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-bug text-red-600 dark:text-red-400"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-mono font-semibold text-primary">{{ error.error_id }}</span>
|
||||
<button onclick="copyToClipboard('{{ error.error_id }}')" class="text-gray-400 dark:text-slate-500 hover:text-primary" title="Copy Error ID">
|
||||
<i class="fas fa-copy text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ errorTypeShort }}</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5 truncate" style="max-width: 300px;" title="{{ error.error_message }}">
|
||||
{{ error.error_message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-xs">
|
||||
<p class="font-mono text-gray-600 dark:text-slate-400 truncate" style="max-width: 200px;" title="{{ error.error_file }}">
|
||||
{{ error.error_file|split('/')|last|split('\\')|last }}
|
||||
</p>
|
||||
<p class="text-gray-500 dark:text-slate-500 mt-0.5">
|
||||
<i class="fas fa-hashtag mr-1"></i>
|
||||
Line {{ error.error_line }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold {{ error.occurrences >= 10 ? 'bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-300' }}">
|
||||
<i class="fas fa-redo mr-1"></i>
|
||||
{{ error.occurrences }}×
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-slate-400">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
{{ error.last_occurred_at|date("M d, H:i") }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if isResolved %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 border border-green-200 dark:border-green-500/20">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
Resolved
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 border border-orange-200 dark:border-orange-500/20">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Unresolved
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/errors/{{ error.error_id }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300" title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if not isResolved %}
|
||||
<button onclick="markResolved('{{ error.error_id }}')" class="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300" title="Mark as Resolved">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="deleteError('{{ error.error_id }}')" class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300" title="Delete Error">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-check-circle text-green-500 dark:text-green-400 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-slate-300 mb-1">No Errors Found</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">
|
||||
{% if currentFilters.resolved or currentFilters.type %}
|
||||
No errors match your filter criteria.
|
||||
{% else %}
|
||||
Great! Your application is running smoothly.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{% if pagination.total_pages > 1 %}
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Page <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.current_page }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total_pages }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{% if pagination.current_page > 1 %}
|
||||
<a href="{{ pagination_url(1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<a href="{{ pagination_url(pagination.current_page - 1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% set range = 2 %}
|
||||
{% set startPage = max(1, pagination.current_page - range) %}
|
||||
{% set endPage = min(pagination.total_pages, pagination.current_page + range) %}
|
||||
|
||||
{% if startPage > 1 %}
|
||||
<a href="{{ pagination_url(1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">1</a>
|
||||
{% if startPage > 2 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for i in startPage..endPage %}
|
||||
{% if i == pagination.current_page %}
|
||||
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">{{ i }}</span>
|
||||
{% else %}
|
||||
<a href="{{ pagination_url(i, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">{{ i }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if endPage < pagination.total_pages %}
|
||||
{% if endPage < pagination.total_pages - 1 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
<a href="{{ pagination_url(pagination.total_pages, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">{{ pagination.total_pages }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if pagination.current_page < pagination.total_pages %}
|
||||
<a href="{{ pagination_url(pagination.current_page + 1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<a href="{{ pagination_url(pagination.total_pages, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Resolution Notes Modal -->
|
||||
<div id="resolutionModal" class="hidden fixed inset-0 bg-gray-600/50 dark:bg-black/50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-gray-200 dark:border-slate-700 w-full max-w-md shadow-lg rounded-lg bg-white dark:bg-slate-800">
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mr-2"></i>
|
||||
Mark Error as Resolved
|
||||
</h3>
|
||||
<button onclick="closeResolutionModal()" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="resolutionNotes" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
Resolution Notes (Optional)
|
||||
</label>
|
||||
<textarea id="resolutionNotes" rows="4"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent resize-none bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="Describe how you resolved this error or any relevant notes..."></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-slate-400">Add any details about the fix or resolution for future reference.</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button onclick="closeResolutionModal()" class="px-4 py-2 bg-gray-200 dark:bg-slate-700 text-gray-800 dark:text-slate-200 rounded-lg hover:bg-gray-300 dark:hover:bg-slate-600 transition-colors text-sm font-medium">Cancel</button>
|
||||
<button onclick="submitResolution()" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-check mr-2"></i>Mark as Resolved
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => { showCopySuccess(); });
|
||||
} else {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
showCopySuccess();
|
||||
}
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
let container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'fixed bottom-4 right-4 z-[9999] space-y-3 max-w-sm';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast bg-white dark:bg-slate-800 border-l-4 border-green-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in';
|
||||
toast.innerHTML = '<div class="flex-shrink-0"><div class="w-8 h-8 bg-green-100 dark:bg-green-500/10 rounded-full flex items-center justify-center"><i class="fas fa-check text-green-600 dark:text-green-400 text-sm"></i></div></div><div class="ml-3 flex-1"><p class="text-sm font-medium text-gray-900 dark:text-white">Success</p><p class="text-sm text-gray-600 dark:text-slate-400 mt-0.5">Copied to clipboard!</p></div><button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300 transition-colors"><i class="fas fa-times text-sm"></i></button>';
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => { toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; toast.style.opacity = '0'; toast.style.transform = 'translateX(100%)'; setTimeout(() => toast.remove(), 300); }, 3000);
|
||||
}
|
||||
|
||||
let currentErrorId = null;
|
||||
|
||||
function markResolved(errorId) {
|
||||
currentErrorId = errorId;
|
||||
document.getElementById('resolutionModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeResolutionModal() {
|
||||
document.getElementById('resolutionModal').classList.add('hidden');
|
||||
document.getElementById('resolutionNotes').value = '';
|
||||
currentErrorId = null;
|
||||
}
|
||||
|
||||
function submitResolution() {
|
||||
if (!currentErrorId) return;
|
||||
const notes = document.getElementById('resolutionNotes').value;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/' + currentErrorId + '/resolve';
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
if (notes) {
|
||||
const notesInput = document.createElement('input');
|
||||
notesInput.type = 'hidden';
|
||||
notesInput.name = 'notes';
|
||||
notesInput.value = notes;
|
||||
form.appendChild(notesInput);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError(errorId) {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/' + errorId + '/delete';
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function toggleSelectAll(checkbox) {
|
||||
document.querySelectorAll('.error-checkbox').forEach(cb => { cb.checked = checkbox.checked; });
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkboxes = document.querySelectorAll('.error-checkbox:checked');
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
selectedCount.textContent = checkboxes.length + ' error(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
}
|
||||
const allCheckboxes = document.querySelectorAll('.error-checkbox');
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkboxes.length > 0 && checkboxes.length < allCheckboxes.length;
|
||||
}
|
||||
|
||||
function getSelectedErrorIds() {
|
||||
return Array.from(document.querySelectorAll('.error-checkbox:checked')).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
document.querySelectorAll('.error-checkbox').forEach(cb => { cb.checked = false; });
|
||||
document.getElementById('select-all').checked = false;
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
const errorIds = getSelectedErrorIds();
|
||||
if (errorIds.length === 0) { alert('Please select at least one error to delete'); return; }
|
||||
if (!confirm(`Are you sure you want to delete ${errorIds.length} error(s) and all their occurrences? This action cannot be undone.`)) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/bulk-delete';
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'error_ids';
|
||||
idsInput.value = JSON.stringify(errorIds);
|
||||
form.appendChild(idsInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,12 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Error - <?= htmlspecialchars($error_type ?? 'Application Error') ?></title>
|
||||
<title>Debug Error - {{ error_type|default('Application Error') }}</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<script>
|
||||
@@ -28,28 +25,15 @@
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
body { background-color: #f8f9fa; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
color: #858585;
|
||||
user-select: none;
|
||||
}
|
||||
.animate-fade-in { animation: fadeIn 0.4s ease-out; }
|
||||
.code-block { background-color: #1e1e1e; color: #d4d4d4; }
|
||||
.line-number { color: #858585; user-select: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen p-6">
|
||||
@@ -84,18 +68,16 @@
|
||||
<!-- Primary Error Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm border-l-4 border-red-500 mb-6 animate-fade-in">
|
||||
<div class="p-6">
|
||||
<!-- Error Header -->
|
||||
<div class="flex items-start mb-6">
|
||||
<div class="flex-shrink-0 w-14 h-14 bg-red-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 text-2xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">
|
||||
<?= htmlspecialchars($error_type ?? 'Error') ?>
|
||||
{{ error_type|default('Error') }}
|
||||
</h2>
|
||||
<p class="text-lg text-gray-700 mb-4"><?= htmlspecialchars($error_message ?? 'An error occurred') ?></p>
|
||||
<p class="text-lg text-gray-700 mb-4">{{ error_message|default('An error occurred') }}</p>
|
||||
|
||||
<!-- Error Location - Most Critical -->
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<i class="fas fa-map-marker-alt text-red-500 mr-2 text-xs"></i>
|
||||
@@ -105,12 +87,12 @@
|
||||
<div>
|
||||
<span class="text-xs font-medium text-gray-600">File:</span>
|
||||
<code class="block mt-1 bg-white px-3 py-2 rounded text-sm text-gray-800 border border-gray-200 font-mono break-all">
|
||||
<?= htmlspecialchars($error_file ?? 'Unknown') ?>
|
||||
{{ error_file|default('Unknown') }}
|
||||
</code>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs font-medium text-gray-600 mr-2">Line:</span>
|
||||
<span class="font-mono text-red-600 font-bold text-lg"><?= htmlspecialchars($error_line ?? '?') ?></span>
|
||||
<span class="font-mono text-red-600 font-bold text-lg">{{ error_line|default('?') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,67 +101,63 @@
|
||||
|
||||
<!-- Quick Info Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Error Reference ID -->
|
||||
<div class="bg-blue-50 rounded-lg border border-blue-200 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide">Error ID</h4>
|
||||
<button onclick="copyToClipboard('<?= htmlspecialchars($error_id ?? 'N/A') ?>')"
|
||||
<button onclick="copyToClipboard('{{ error_id|default('N/A') }}')"
|
||||
class="text-primary hover:text-primary-dark" title="Copy Error ID">
|
||||
<i class="fas fa-copy text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<code class="text-sm font-mono font-bold text-primary"><?= htmlspecialchars($error_id ?? 'N/A') ?></code>
|
||||
<code class="text-sm font-mono font-bold text-primary">{{ error_id|default('N/A') }}</code>
|
||||
<p class="text-xs text-gray-600 mt-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use for bug reports
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Request Info -->
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">Request</h4>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm">
|
||||
<span class="font-mono font-bold text-gray-900"><?= htmlspecialchars($request_method ?? 'GET') ?></span>
|
||||
<span class="font-mono font-bold text-gray-900">{{ request_method|default('GET') }}</span>
|
||||
</p>
|
||||
<code class="text-xs text-gray-600 font-mono block truncate" title="<?= htmlspecialchars($request_uri ?? '/') ?>">
|
||||
<?= htmlspecialchars($request_uri ?? '/') ?>
|
||||
<code class="text-xs text-gray-600 font-mono block truncate" title="{{ request_uri|default('/') }}">
|
||||
{{ request_uri|default('/') }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Context -->
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">User</h4>
|
||||
<?php if ($user_info): ?>
|
||||
<p class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($user_info['username']) ?></p>
|
||||
{% if user_info %}
|
||||
<p class="text-sm font-semibold text-gray-900">{{ user_info.username }}</p>
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-user mr-1"></i>
|
||||
<?= htmlspecialchars($user_info['role']) ?>
|
||||
{{ user_info.role }}
|
||||
</p>
|
||||
<?php else: ?>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500">
|
||||
<i class="fas fa-user-slash mr-1"></i>
|
||||
Guest (Not logged in)
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- System Info -->
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">System</h4>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-code mr-1"></i>
|
||||
PHP <?= htmlspecialchars($php_version ?? PHP_VERSION) ?>
|
||||
PHP {{ php_version|default('unknown') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-memory mr-1"></i>
|
||||
<?= round(($memory_usage ?? memory_get_usage(true)) / 1024 / 1024, 2) ?>MB
|
||||
{{ memory_usage_mb|default(0) }}MB
|
||||
</p>
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<?= date('H:i:s', strtotime($occurred_at ?? 'now')) ?>
|
||||
{{ occurred_at|default('now')|date("H:i:s") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,8 +171,7 @@
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Stack Trace -->
|
||||
<?php if (!empty($stack_trace)): ?>
|
||||
{% if stack_trace is defined and stack_trace %}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -211,24 +188,20 @@
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="code-block rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto border border-gray-700" id="stack-trace">
|
||||
<?php
|
||||
$traceLines = explode("\n", $stack_trace);
|
||||
foreach ($traceLines as $index => $line) {
|
||||
if (trim($line)) {
|
||||
echo '<div class="flex font-mono text-sm">';
|
||||
echo '<span class="line-number mr-4 text-right" style="min-width: 2rem">' . str_pad($index, 2, '0', STR_PAD_LEFT) . '</span>';
|
||||
echo '<span class="flex-1 text-green-400">' . htmlspecialchars($line) . '</span>';
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
{% for line in stack_trace|split("\n") %}
|
||||
{% if line|trim %}
|
||||
<div class="flex font-mono text-sm">
|
||||
<span class="line-number mr-4 text-right" style="min-width: 2rem">{{ '%02d'|format(loop.index0) }}</span>
|
||||
<span class="flex-1 text-green-400">{{ line }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<!-- Request Data -->
|
||||
<?php if (!empty($request_data)): ?>
|
||||
{% if request_data is defined and request_data %}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
|
||||
<button onclick="toggleSection('request-data')"
|
||||
class="w-full px-6 py-4 border-b border-gray-200 bg-gray-50 text-left hover:bg-gray-100 transition-colors">
|
||||
@@ -237,7 +210,7 @@
|
||||
<i class="fas fa-paper-plane text-blue-500 mr-2 text-sm"></i>
|
||||
Request Data
|
||||
<span class="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded font-medium">
|
||||
<?= count($request_data) ?>
|
||||
{{ request_data|length }}
|
||||
</span>
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="request-data-chevron"></i>
|
||||
@@ -245,20 +218,24 @@
|
||||
</button>
|
||||
<div id="request-data" class="hidden p-6">
|
||||
<div class="space-y-3">
|
||||
<?php foreach ($request_data as $key => $value): ?>
|
||||
{% for key, value in request_data %}
|
||||
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<span class="text-xs font-semibold text-gray-700 uppercase tracking-wide block mb-1">
|
||||
<?= htmlspecialchars($key) ?>
|
||||
{{ key }}
|
||||
</span>
|
||||
<code class="text-sm text-gray-800 font-mono block break-all">
|
||||
<?= htmlspecialchars(is_array($value) ? json_encode($value, JSON_PRETTY_PRINT) : $value) ?>
|
||||
{% if value is iterable %}
|
||||
{{ value|json_encode(constant('JSON_PRETTY_PRINT')) }}
|
||||
{% else %}
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
</code>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -277,24 +254,24 @@
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600">Method</span>
|
||||
<span class="font-mono font-bold text-gray-900"><?= htmlspecialchars($request_method ?? 'GET') ?></span>
|
||||
<span class="font-mono font-bold text-gray-900">{{ request_method|default('GET') }}</span>
|
||||
</div>
|
||||
<div class="py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600 block mb-1">URI</span>
|
||||
<code class="text-xs text-gray-800 font-mono block break-all bg-gray-50 px-2 py-1 rounded">
|
||||
<?= htmlspecialchars($request_uri ?? '/') ?>
|
||||
{{ request_uri|default('/') }}
|
||||
</code>
|
||||
</div>
|
||||
<div class="py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600 block mb-1">IP Address</span>
|
||||
<code class="text-xs text-gray-800 font-mono block bg-gray-50 px-2 py-1 rounded">
|
||||
<?= htmlspecialchars($ip_address ?? 'Unknown') ?>
|
||||
{{ ip_address|default('Unknown') }}
|
||||
</code>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<span class="font-medium text-gray-600 block mb-1">User Agent</span>
|
||||
<code class="text-xs text-gray-600 font-mono block break-all bg-gray-50 px-2 py-1 rounded">
|
||||
<?= htmlspecialchars($user_agent ?? 'Unknown') ?>
|
||||
{{ user_agent|default('Unknown') }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,26 +290,25 @@
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600">PHP Version</span>
|
||||
<span class="font-mono text-gray-900"><?= htmlspecialchars($php_version ?? PHP_VERSION) ?></span>
|
||||
<span class="font-mono text-gray-900">{{ php_version|default('unknown') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600">Memory Usage</span>
|
||||
<span class="font-mono text-gray-900"><?= round(($memory_usage ?? memory_get_usage(true)) / 1024 / 1024, 2) ?>MB</span>
|
||||
<span class="font-mono text-gray-900">{{ memory_usage_mb|default(0) }}MB</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600">Peak Memory</span>
|
||||
<span class="font-mono text-gray-900"><?= round(memory_get_peak_usage(true) / 1024 / 1024, 2) ?>MB</span>
|
||||
<span class="font-mono text-gray-900">{{ peak_memory_mb|default(0) }}MB</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="font-medium text-gray-600">Timestamp</span>
|
||||
<span class="font-mono text-gray-900 text-xs"><?= date('Y-m-d H:i:s T', strtotime($occurred_at ?? 'now')) ?></span>
|
||||
<span class="font-mono text-gray-900 text-xs">{{ occurred_at|default('now')|date("Y-m-d H:i:s T") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Data -->
|
||||
<?php if (!empty($session_data)): ?>
|
||||
{% if session_data is defined and session_data %}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
|
||||
<button onclick="toggleSection('session-data')"
|
||||
class="w-full px-6 py-4 border-b border-gray-200 bg-gray-50 text-left hover:bg-gray-100 transition-colors">
|
||||
@@ -341,7 +317,7 @@
|
||||
<i class="fas fa-user-lock text-orange-500 mr-2 text-sm"></i>
|
||||
Session Data
|
||||
<span class="ml-2 text-xs bg-orange-100 text-orange-800 px-2 py-1 rounded font-medium">
|
||||
<?= count($session_data) ?>
|
||||
{{ session_data|length }}
|
||||
</span>
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="session-data-chevron"></i>
|
||||
@@ -357,20 +333,24 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<?php foreach ($session_data as $key => $value): ?>
|
||||
{% for key, value in session_data %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-mono text-gray-700 align-top"><?= htmlspecialchars($key) ?></td>
|
||||
<td class="px-3 py-2 font-mono text-gray-700 align-top">{{ key }}</td>
|
||||
<td class="px-3 py-2 font-mono text-gray-600 break-all">
|
||||
<?= htmlspecialchars(is_array($value) ? json_encode($value) : $value) ?>
|
||||
{% if value is iterable %}
|
||||
{{ value|json_encode }}
|
||||
{% else %}
|
||||
{{ value }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -409,12 +389,11 @@
|
||||
<div class="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
<i class="fas fa-globe text-primary mr-1"></i>
|
||||
Domain Monitor © <?= date('Y') ?> • Development Mode
|
||||
Domain Monitor © {{ "now"|date("Y") }} • Development Mode
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script>
|
||||
function toggleSection(sectionId) {
|
||||
const section = document.getElementById(sectionId);
|
||||
@@ -422,14 +401,10 @@
|
||||
|
||||
if (section.classList.contains('hidden')) {
|
||||
section.classList.remove('hidden');
|
||||
if (chevron) {
|
||||
chevron.style.transform = 'rotate(180deg)';
|
||||
}
|
||||
if (chevron) chevron.style.transform = 'rotate(180deg)';
|
||||
} else {
|
||||
section.classList.add('hidden');
|
||||
if (chevron) {
|
||||
chevron.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
if (chevron) chevron.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,14 +427,12 @@
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopySuccess();
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
@@ -467,34 +440,29 @@
|
||||
const stackTraceElement = document.getElementById('stack-trace');
|
||||
const lines = stackTraceElement.querySelectorAll('div');
|
||||
let stackText = '';
|
||||
|
||||
lines.forEach(line => {
|
||||
const textSpan = line.querySelector('span:last-child');
|
||||
if (textSpan) {
|
||||
stackText += textSpan.textContent + '\n';
|
||||
}
|
||||
if (textSpan) stackText += textSpan.textContent + '\n';
|
||||
});
|
||||
|
||||
copyToClipboard(stackText.trim());
|
||||
}
|
||||
|
||||
function copyErrorReport() {
|
||||
const errorType = <?= json_encode($error_type ?? 'Error') ?>;
|
||||
const errorMessage = <?= json_encode($error_message ?? 'Unknown error') ?>;
|
||||
const errorFile = <?= json_encode($error_file ?? 'Unknown') ?>;
|
||||
const errorLine = <?= json_encode($error_line ?? '?') ?>;
|
||||
const errorId = <?= json_encode($error_id ?? 'N/A') ?>;
|
||||
const phpVersion = <?= json_encode($php_version ?? PHP_VERSION) ?>;
|
||||
const requestMethod = <?= json_encode($request_method ?? 'GET') ?>;
|
||||
const requestUri = <?= json_encode($request_uri ?? '/') ?>;
|
||||
const userAgent = <?= json_encode($user_agent ?? 'Unknown') ?>;
|
||||
const ipAddress = <?= json_encode($ip_address ?? 'Unknown') ?>;
|
||||
const timestamp = <?= json_encode(date('Y-m-d H:i:s', strtotime($occurred_at ?? 'now'))) ?>;
|
||||
const errorType = {{ (error_type|default('Error'))|json_encode|raw }};
|
||||
const errorMessage = {{ (error_message|default('Unknown error'))|json_encode|raw }};
|
||||
const errorFile = {{ (error_file|default('Unknown'))|json_encode|raw }};
|
||||
const errorLine = {{ (error_line|default('?'))|json_encode|raw }};
|
||||
const errorId = {{ (error_id|default('N/A'))|json_encode|raw }};
|
||||
const phpVersion = {{ (php_version|default('unknown'))|json_encode|raw }};
|
||||
const requestMethod = {{ (request_method|default('GET'))|json_encode|raw }};
|
||||
const requestUri = {{ (request_uri|default('/'))|json_encode|raw }};
|
||||
const userAgent = {{ (user_agent|default('Unknown'))|json_encode|raw }};
|
||||
const ipAddress = {{ (ip_address|default('Unknown'))|json_encode|raw }};
|
||||
const timestamp = {{ (occurred_at|default('now'))|json_encode|raw }};
|
||||
|
||||
const userInfo = <?= json_encode($user_info ?? null) ?>;
|
||||
const userInfo = {{ (user_info|default(null))|json_encode|raw }};
|
||||
const userText = userInfo ? `${userInfo.username} (${userInfo.role}, ID: ${userInfo.id})` : 'Guest (Not logged in)';
|
||||
|
||||
// Get stack trace
|
||||
const stackTraceElement = document.getElementById('stack-trace');
|
||||
let stackTrace = 'Not available';
|
||||
if (stackTraceElement) {
|
||||
@@ -502,9 +470,7 @@
|
||||
let stackText = '';
|
||||
lines.forEach(line => {
|
||||
const textSpan = line.querySelector('span:last-child');
|
||||
if (textSpan) {
|
||||
stackText += textSpan.textContent + '\n';
|
||||
}
|
||||
if (textSpan) stackText += textSpan.textContent + '\n';
|
||||
});
|
||||
stackTrace = stackText.trim();
|
||||
}
|
||||
@@ -532,8 +498,8 @@ USER CONTEXT:
|
||||
|
||||
SYSTEM INFORMATION:
|
||||
- PHP Version: ${phpVersion}
|
||||
- Memory Usage: ${<?= round(($memory_usage ?? memory_get_usage(true)) / 1024 / 1024, 2) ?>}MB
|
||||
- Peak Memory: ${<?= round(memory_get_peak_usage(true) / 1024 / 1024, 2) ?>}MB
|
||||
- Memory Usage: {{ memory_usage_mb|default(0) }}MB
|
||||
- Peak Memory: {{ peak_memory_mb|default(0) }}MB
|
||||
|
||||
STACK TRACE:
|
||||
${stackTrace}
|
||||
@@ -562,4 +528,3 @@ Please include this report when reporting bugs.`;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
<?php
|
||||
$title = 'Create Notification Group';
|
||||
$pageTitle = 'Create Notification Group';
|
||||
$pageDescription = 'Set up a new notification group for your domains';
|
||||
$pageIcon = 'fas fa-plus-circle';
|
||||
ob_start();
|
||||
?>
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
<!-- Main Form -->
|
||||
{% set title = 'Create Notification Group' %}
|
||||
{% set pageTitle = 'Create Notification Group' %}
|
||||
{% set pageDescription = 'Set up a new notification group for your domains' %}
|
||||
{% set pageIcon = 'fas fa-plus-circle' %}
|
||||
|
||||
{% block content %}
|
||||
{# Main Form #}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-bell text-gray-400 mr-2 text-sm"></i>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-bell text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
|
||||
Group Information
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/groups/store" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
<!-- Group Name -->
|
||||
{{ csrf_field() }}
|
||||
{# Group Name #}
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Group Name *
|
||||
</label>
|
||||
<input type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
<input type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., Production Alerts, Team Notifications"
|
||||
required
|
||||
autofocus>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Choose a descriptive name for this notification group
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{# Description #}
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Description (Optional)
|
||||
</label>
|
||||
<textarea id="description"
|
||||
name="description"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
<textarea id="description"
|
||||
name="description"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
rows="4"
|
||||
placeholder="Add details about this notification group, its purpose, or who should be notified..."></textarea>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Optional: Add notes to help identify this group's purpose
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
{# Action Buttons #}
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||
<button type="submit"
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-plus-circle mr-2"></i>
|
||||
Create Group
|
||||
</button>
|
||||
<a href="/groups"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<a href="/groups"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
@@ -68,8 +68,8 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Section -->
|
||||
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
{# Info Section #}
|
||||
<div class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
@@ -77,8 +77,8 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">Next Steps</h3>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">Next Steps</h3>
|
||||
<ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
|
||||
<li class="flex items-center">
|
||||
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
|
||||
<span class="ml-2">After creating the group, you'll be able to add notification channels (Email, Telegram, Discord, Slack, Mattermost, Pushover, Webhook)</span>
|
||||
@@ -96,8 +96,4 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,32 @@
|
||||
<?php
|
||||
$title = 'Notification Groups';
|
||||
$pageTitle = 'Notification Groups';
|
||||
$pageDescription = 'Manage notification channels and assignments';
|
||||
$pageIcon = 'fas fa-bell';
|
||||
ob_start();
|
||||
?>
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
{% set title = 'Notification Groups' %}
|
||||
{% set pageTitle = 'Notification Groups' %}
|
||||
{% set pageDescription = 'Manage notification channels and assignments' %}
|
||||
{% set pageIcon = 'fas fa-bell' %}
|
||||
|
||||
{% block content %}
|
||||
{# Quick Actions #}
|
||||
<div class="mb-4 flex gap-2 justify-end">
|
||||
<!-- Export Dropdown -->
|
||||
{# Export Dropdown #}
|
||||
<div class="relative" id="groupExportDropdownWrapper">
|
||||
<button onclick="document.getElementById('groupExportMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Export
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="groupExportMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
|
||||
<a href="/groups/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
<div id="groupExportMenu" class="hidden absolute right-0 mt-1 w-44 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-gray-200 dark:border-slate-700 z-30 overflow-hidden">
|
||||
<a href="/groups/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
|
||||
Export as CSV
|
||||
</a>
|
||||
<a href="/groups/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
|
||||
<a href="/groups/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors border-t border-gray-100 dark:border-slate-700">
|
||||
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
|
||||
Export as JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Import Button -->
|
||||
{# Import Button #}
|
||||
<button onclick="document.getElementById('groupImportModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import
|
||||
@@ -37,67 +37,67 @@ ob_start();
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
{# Info Card #}
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-info-circle text-blue-500 text-lg"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">About Notification Groups</h3>
|
||||
<p class="text-xs text-gray-600 leading-relaxed">
|
||||
Notification groups allow you to organize your notification channels. You can create multiple channels
|
||||
(Email, Telegram, Discord, Slack, Mattermost, Pushover, Webhook) within each group, then assign domains to the group. When a domain
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">About Notification Groups</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 leading-relaxed">
|
||||
Notification groups allow you to organize your notification channels. You can create multiple channels
|
||||
(Email, Telegram, Discord, Slack, Mattermost, Pushover, Webhook) within each group, then assign domains to the group. When a domain
|
||||
is about to expire, all active channels in its group will receive notifications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when groups are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
{# Groups List #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
{# Bulk Actions Bar (shown when groups are selected) #}
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 dark:bg-blue-500/10 border-b border-blue-200 dark:border-blue-500/30 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700 dark:text-slate-300"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
{% if auth.isAdmin %}
|
||||
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-slate-400 hover:text-gray-900 dark:hover:text-white hover:bg-blue-100 dark:hover:bg-blue-500/20 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php if (!empty($groups)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
{% if groups is not empty %}
|
||||
{# Table View (Desktop) #}
|
||||
<div class="hidden md:block overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 text-primary focus:ring-primary">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary">
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Group Name</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Channels</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Domains</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Group Name</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Channels</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Domains</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150">
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for group in groups %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
<input type="checkbox" class="group-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= $group['id'] ?>" onchange="updateBulkActions()">
|
||||
<input type="checkbox" class="group-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="{{ group.id }}" onchange="updateBulkActions()">
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
@@ -105,108 +105,108 @@ ob_start();
|
||||
<i class="fas fa-bell text-primary"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($group['name']) ?></div>
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ group.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-700 max-w-xs truncate">
|
||||
<?= htmlspecialchars($group['description'] ?? 'No description') ?>
|
||||
<div class="text-sm text-gray-700 dark:text-slate-300 max-w-xs truncate">
|
||||
{{ group.description|default('No description') }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-400">
|
||||
<i class="fas fa-plug mr-1"></i>
|
||||
<?= $group['channel_count'] ?> channel<?= $group['channel_count'] != 1 ? 's' : '' ?>
|
||||
{{ group.channel_count }} channel{{ group.channel_count != 1 ? 's' : '' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 dark:bg-green-500/20 text-green-800 dark:text-green-400">
|
||||
<i class="fas fa-globe mr-1"></i>
|
||||
<?= $group['domain_count'] ?> domain<?= $group['domain_count'] != 1 ? 's' : '' ?>
|
||||
{{ group.domain_count }} domain{{ group.domain_count != 1 ? 's' : '' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/groups/<?= $group['id'] ?>/edit" class="text-blue-600 hover:text-blue-800" title="Manage">
|
||||
<a href="/groups/{{ group.id }}/edit" class="text-blue-600 hover:text-blue-800" title="Manage">
|
||||
<i class="fas fa-cog"></i>
|
||||
</a>
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button onclick="transferGroup(<?= $group['id'] ?>, '<?= htmlspecialchars($group['name']) ?>')"
|
||||
class="text-green-600 hover:text-green-800"
|
||||
{% if auth.isAdmin %}
|
||||
<button onclick="transferGroup({{ group.id }}, '{{ group.name|e('js') }}')"
|
||||
class="text-green-600 hover:text-green-800"
|
||||
title="Transfer Group">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<form method="POST" action="/groups/<?= $group['id'] ?>/delete" class="inline" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
{% endif %}
|
||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="inline" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800" title="Delete"
|
||||
aria-label="Delete group <?= htmlspecialchars($group['name']) ?>">
|
||||
aria-label="Delete group {{ group.name }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Card View (Mobile) -->
|
||||
<div class="md:hidden divide-y divide-gray-200">
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<div class="p-6 hover:bg-gray-50 transition-colors duration-150">
|
||||
{# Card View (Mobile) #}
|
||||
<div class="md:hidden divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for group in groups %}
|
||||
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-bell text-primary"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="font-semibold text-gray-900"><?= htmlspecialchars($group['name']) ?></h3>
|
||||
<p class="text-sm text-gray-500"><?= htmlspecialchars($group['description'] ?? 'No description') ?></p>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ group.name }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">{{ group.description|default('No description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex space-x-3 mb-3">
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-400">
|
||||
<i class="fas fa-plug mr-1"></i>
|
||||
<?= $group['channel_count'] ?> channels
|
||||
{{ group.channel_count }} channels
|
||||
</span>
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-800 dark:text-green-400">
|
||||
<i class="fas fa-globe mr-1"></i>
|
||||
<?= $group['domain_count'] ?> domains
|
||||
{{ group.domain_count }} domains
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<a href="/groups/<?= $group['id'] ?>/edit" class="flex-1 px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors">
|
||||
<a href="/groups/{{ group.id }}/edit" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
||||
<i class="fas fa-cog mr-1"></i> Manage
|
||||
</a>
|
||||
<form method="POST" action="/groups/<?= $group['id'] ?>/delete" class="flex-1" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
<button type="submit" class="w-full px-3 py-1.5 bg-red-50 text-red-600 rounded text-center text-sm hover:bg-red-100 transition-colors">
|
||||
<form method="POST" action="/groups/{{ group.id }}/delete" class="flex-1" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="w-full px-3 py-1.5 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors">
|
||||
<i class="fas fa-trash mr-1"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<?php else: ?>
|
||||
{% else %}
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-bell-slash text-gray-300 text-6xl"></i>
|
||||
<i class="fas fa-bell-slash text-gray-300 dark:text-slate-600 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Notification Groups</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Create your first notification group to start receiving alerts</p>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-slate-300 mb-1">No Notification Groups</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">Create your first notification group to start receiving alerts</p>
|
||||
<a href="/groups/create" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create Your First Group
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -223,15 +223,14 @@ function updateBulkActions() {
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
selectedCount.textContent = checkboxes.length + ' group(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
|
||||
const allCheckboxes = document.querySelectorAll('.group-checkbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
@@ -255,139 +254,136 @@ function getSelectedGroupIds() {
|
||||
|
||||
function bulkDelete() {
|
||||
const groupIds = getSelectedGroupIds();
|
||||
|
||||
|
||||
if (groupIds.length === 0) {
|
||||
alert('Please select at least one group to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${groupIds.length} group(s)? Domains will be unassigned from these groups.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/groups/bulk-delete';
|
||||
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'group_ids';
|
||||
idsInput.value = JSON.stringify(groupIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
// Transfer single group
|
||||
function transferGroup(groupId, groupName) {
|
||||
const users = <?= json_encode($users ?? []) ?>;
|
||||
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const userOptions = users.map(user =>
|
||||
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Group</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer group "${groupName}" to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
<input type="hidden" name="group_id" value="${groupId}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Group</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer group "${groupName}" to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/transfer">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="group_id" value="${groupId}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Bulk transfer groups
|
||||
function bulkTransfer() {
|
||||
const groupIds = getSelectedGroupIds();
|
||||
if (groupIds.length === 0) {
|
||||
alert('Please select groups to transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = <?= json_encode($users ?? []) ?>;
|
||||
|
||||
|
||||
const users = {{ users|default([])|json_encode|raw }};
|
||||
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const userOptions = users.map(user =>
|
||||
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Groups</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer ${groupIds.length} selected group(s) to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/bulk-transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
${groupIds.map(id =>
|
||||
`<input type="hidden" name="group_ids[]" value="${id}">`
|
||||
).join('')}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer All
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Groups</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer ${groupIds.length} selected group(s) to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/bulk-transfer">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
${groupIds.map(id =>
|
||||
`<input type="hidden" name="group_ids[]" value="${id}">`
|
||||
).join('')}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-sm font-medium text-gray-700 dark:text-slate-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer All
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Close export dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const wrapper = document.getElementById('groupExportDropdownWrapper');
|
||||
if (wrapper && !wrapper.contains(e.target)) {
|
||||
@@ -395,7 +391,6 @@ document.addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
// Close import modal on backdrop click
|
||||
document.getElementById('groupImportModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
this.classList.add('hidden');
|
||||
@@ -403,54 +398,54 @@ document.getElementById('groupImportModal')?.addEventListener('click', function(
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Import Modal -->
|
||||
{# Import Modal #}
|
||||
<div id="groupImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>Import Notification Groups
|
||||
</h3>
|
||||
<button onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
|
||||
<button onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/groups/import" enctype="multipart/form-data" id="groupImportForm">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
<!-- Drag & Drop Zone -->
|
||||
{# Drag & Drop Zone #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Select File</label>
|
||||
<div id="groupDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="groupDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="groupFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="groupDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1">or</p>
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="groupDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700" id="groupFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="groupFileSize"></p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="groupFileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="groupFileSize"></p>
|
||||
<button type="button" id="groupFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600">CSV: <code class="bg-white px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 mt-0.5">JSON: array of group objects with nested channels array</p>
|
||||
<p class="text-xs text-gray-500 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV: <code class="bg-white dark:bg-slate-700 px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of group objects with nested channels array</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="groupImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
@@ -462,7 +457,6 @@ document.getElementById('groupImportModal')?.addEventListener('click', function(
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Group Import drag-and-drop & loading ---
|
||||
(function() {
|
||||
const dropzone = document.getElementById('groupDropzone');
|
||||
const fileInput = document.getElementById('groupFileInput');
|
||||
@@ -540,8 +534,4 @@ document.getElementById('groupImportModal')?.addEventListener('click', function(
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
@@ -24,7 +24,6 @@
|
||||
<body class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
|
||||
<!-- Success Icon -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4">
|
||||
<i class="fas fa-check-circle text-green-600 text-5xl"></i>
|
||||
@@ -33,7 +32,6 @@
|
||||
<p class="text-gray-600">Domain Monitor is ready to use</p>
|
||||
</div>
|
||||
|
||||
<!-- Important Notice -->
|
||||
<div class="bg-amber-50 border-2 border-amber-400 rounded-lg p-6 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 text-2xl mr-4"></i>
|
||||
@@ -45,11 +43,11 @@
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-600">Username:</span>
|
||||
<span class="text-sm font-mono font-bold text-gray-900 select-all"><?= htmlspecialchars($adminUsername ?? 'admin') ?></span>
|
||||
<span class="text-sm font-mono font-bold text-gray-900 select-all">{{ adminUsername|default('admin') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-600">Password:</span>
|
||||
<span class="text-sm font-mono font-bold text-gray-900 select-all"><?= htmlspecialchars($adminPassword ?? '********') ?></span>
|
||||
<span class="text-sm font-mono font-bold text-gray-900 select-all">{{ adminPassword|default('********') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +55,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Checklist -->
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-6 mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4">Installation Summary</h3>
|
||||
<div class="space-y-3">
|
||||
@@ -80,15 +77,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Steps -->
|
||||
<div class="bg-blue-50 rounded-lg border border-blue-200 p-4 mb-6">
|
||||
<h3 class="text-sm font-semibold text-blue-900 mb-3">
|
||||
<i class="fas fa-lightbulb mr-2"></i>Next Steps
|
||||
</h3>
|
||||
<ol class="text-sm text-blue-800 space-y-1 ml-5 list-decimal">
|
||||
<li>Log in with your admin credentials</li>
|
||||
<li>Configure email settings (Settings → Email)</li>
|
||||
<li>Import TLD registry data (TLD Registry → Import TLDs)</li>
|
||||
<li>Configure email settings (Settings → Email)</li>
|
||||
<li>Import TLD registry data (TLD Registry → Import TLDs)</li>
|
||||
<li>Add your first domain</li>
|
||||
<li>Set up notification groups</li>
|
||||
<li>Configure cron job for automated monitoring</li>
|
||||
@@ -102,7 +98,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-6">
|
||||
<p class="text-gray-500 text-xs">© <?= date('Y') ?> <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a></p>
|
||||
<p class="text-gray-500 text-xs">© {{ "now"|date("Y") }} <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -24,7 +24,6 @@
|
||||
<body class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-lg mb-4">
|
||||
<i class="fas fa-arrow-up text-white text-3xl"></i>
|
||||
@@ -33,7 +32,6 @@
|
||||
<p class="text-gray-600">New database migrations are available</p>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="bg-amber-50 border border-amber-300 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 text-xl mr-3"></i>
|
||||
@@ -44,38 +42,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Migrations -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-3">Pending Migrations</h2>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<ul class="space-y-2">
|
||||
<?php foreach ($migrations as $migration): ?>
|
||||
{% for migration in migrations %}
|
||||
<li class="flex items-center text-sm">
|
||||
<i class="fas fa-circle text-xs text-gray-400 mr-3"></i>
|
||||
<span class="font-mono text-gray-700"><?= htmlspecialchars($migration) ?></span>
|
||||
<span class="font-mono text-gray-700">{{ migration }}</span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="mt-3 pt-3 border-t border-gray-300">
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
<i class="fas fa-database mr-2"></i>
|
||||
Total: <?= count($migrations) ?> migration(s)
|
||||
Total: {{ migrations|length }} migration(s)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
{% if flash.error is defined %}
|
||||
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||
<span class="text-sm text-red-700">{{ flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['error']); endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<!-- Actions -->
|
||||
<form method="POST" action="/install/update" class="space-y-3">
|
||||
<button type="submit" class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
@@ -89,7 +84,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-6">
|
||||
<p class="text-gray-500 text-xs">© <?= date('Y') ?> <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a></p>
|
||||
<p class="text-gray-500 text-xs">© {{ "now"|date("Y") }} <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -23,9 +23,7 @@
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-2xl w-full">
|
||||
<!-- Installer Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
|
||||
<!-- Logo and Title -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-lg mb-4">
|
||||
<i class="fas fa-globe text-white text-3xl"></i>
|
||||
@@ -34,7 +32,6 @@
|
||||
<p class="text-gray-600">Welcome! Let's set up your monitoring system</p>
|
||||
</div>
|
||||
|
||||
<!-- Installation Steps -->
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-6 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4">Installation Steps</h2>
|
||||
<div class="space-y-3">
|
||||
@@ -62,17 +59,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
{% if flash.error is defined %}
|
||||
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||
<span class="text-sm text-red-700">{{ flash.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_SESSION['error']); endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<!-- Installation Form -->
|
||||
<form method="POST" action="/install/run" class="space-y-5">
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Administrator Account</h3>
|
||||
@@ -142,9 +137,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-6">
|
||||
<p class="text-gray-500 text-xs">© <?= date('Y') ?> <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a></p>
|
||||
<p class="text-gray-500 text-xs">© {{ "now"|date("Y") }} <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-600 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +1,8 @@
|
||||
<?php
|
||||
/**
|
||||
* Base Layout Template
|
||||
* Contains: HTML structure, meta tags, CSS/JS includes, global stats
|
||||
*/
|
||||
{#
|
||||
# Base Layout Template
|
||||
# Contains: HTML structure, meta tags, CSS/JS includes, global stats
|
||||
#}
|
||||
|
||||
// Get current user ID (used for both notifications and stats)
|
||||
$userId = \Core\Auth::id();
|
||||
|
||||
// Fetch notifications for top nav (available on all pages)
|
||||
if ($userId) {
|
||||
$notificationData = \App\Helpers\LayoutHelper::getNotifications($userId);
|
||||
$recentNotifications = $notificationData['items'];
|
||||
$unreadNotifications = $notificationData['unread_count'];
|
||||
// Update badge in top menu (admin only, uses cached update check data)
|
||||
$updateBadge = \Core\Auth::isAdmin() ? \App\Helpers\LayoutHelper::getUpdateBadgeInfo() : ['show' => false, 'available' => false, 'label' => ''];
|
||||
} else {
|
||||
$recentNotifications = [];
|
||||
$unreadNotifications = 0;
|
||||
$updateBadge = ['show' => false, 'available' => false, 'label' => ''];
|
||||
}
|
||||
|
||||
// Get domain stats for sidebar (available on all pages)
|
||||
if (!isset($domainStats)) {
|
||||
$domainStats = \App\Helpers\LayoutHelper::getDomainStats();
|
||||
}
|
||||
|
||||
// Get application settings from database
|
||||
if (!isset($appName)) {
|
||||
$appSettings = \App\Helpers\LayoutHelper::getAppSettings();
|
||||
$appName = $appSettings['app_name'];
|
||||
$appTimezone = $appSettings['app_timezone'];
|
||||
$appVersion = $appSettings['app_version'];
|
||||
|
||||
// Note: Timezone is now set early in public/index.php (before controllers run)
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -45,24 +13,25 @@ if (!isset($appName)) {
|
||||
<meta name="author" content="Domain Monitor">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<!-- Title -->
|
||||
<title><?= $title ?? 'Domain Monitor' ?> - <?= $appName ?></title>
|
||||
{# Title #}
|
||||
<title>{{ title|default('Domain Monitor') }} - {{ appName }}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
{# Favicon #}
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
{# Tailwind CSS #}
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Flag Icons -->
|
||||
{# Flag Icons #}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flag-icons/7.5.0/css/flag-icons.min.css" referrerpolicy="no-referrer" />
|
||||
|
||||
<!-- Font Awesome -->
|
||||
{# Font Awesome #}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<!-- Tailwind Configuration -->
|
||||
{# Tailwind Configuration #}
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
@@ -81,37 +50,33 @@ if (!isset($appName)) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Custom Styles -->
|
||||
{# Theme initialization (prevent flash) #}
|
||||
<script>
|
||||
(function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# Custom Styles #}
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
|
||||
<!-- Custom Page Styles (optional) -->
|
||||
<?php if (isset($customStyles)): ?>
|
||||
<style><?= $customStyles ?></style>
|
||||
<?php endif; ?>
|
||||
{# Custom Page Styles (optional) #}
|
||||
{% if customStyles is defined %}
|
||||
<style>{{ customStyles|raw }}</style>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
/* Sidebar full height */
|
||||
.sidebar {
|
||||
height: 100vh;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
transition: transform 0.3s ease-in-out, background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Mobile sidebar overlay */
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 25;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
|
||||
}
|
||||
.sidebar-overlay.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
@@ -133,152 +98,133 @@ if (!isset($appName)) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Active sidebar link */
|
||||
.sidebar-link.active {
|
||||
background: #374151;
|
||||
border-left: 4px solid #4A90E2;
|
||||
/* Sidebar link hover effect */
|
||||
.sidebar-link {
|
||||
position: relative;
|
||||
}
|
||||
.sidebar-link::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 0;
|
||||
background: linear-gradient(to bottom, #3b82f6, #6366f1);
|
||||
border-radius: 0 3px 3px 0;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
.sidebar-link:hover::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
/* Mobile-friendly dropdown widths */
|
||||
@media (max-width: 480px) {
|
||||
#notificationsDropdown {
|
||||
width: calc(100vw - 2rem);
|
||||
right: -0.5rem;
|
||||
}
|
||||
#userDropdown {
|
||||
width: calc(100vw - 2rem);
|
||||
right: -0.5rem;
|
||||
}
|
||||
/* Custom scrollbar for sidebar */
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
.dark .sidebar::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.3);
|
||||
}
|
||||
.dark .sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<body class="bg-gray-50 dark:bg-gray-900 transition-colors duration-200">
|
||||
|
||||
<?php include __DIR__ . '/top-nav.php'; ?>
|
||||
{% include 'layout/top-nav.twig' %}
|
||||
|
||||
<!-- Mobile Sidebar Overlay -->
|
||||
<div id="sidebarOverlay" class="sidebar-overlay md:hidden" onclick="closeSidebar()"></div>
|
||||
{# Mobile Sidebar Overlay #}
|
||||
<div id="sidebarOverlay" class="fixed inset-0 bg-black/50 z-20 hidden md:hidden" onclick="closeSidebar()"></div>
|
||||
|
||||
<?php include __DIR__ . '/sidebar.php'; ?>
|
||||
{% include 'layout/sidebar.twig' %}
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="md:ml-64 pt-16 min-h-screen bg-gray-50">
|
||||
{# Main Content Area #}
|
||||
<main class="md:ml-64 pt-16 min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div class="p-6">
|
||||
<!-- Flash Messages -->
|
||||
<?php include __DIR__ . '/messages.php'; ?>
|
||||
{# Flash Messages #}
|
||||
{% include 'layout/messages.twig' %}
|
||||
|
||||
<!-- Page Content -->
|
||||
<?php if (isset($content)): ?>
|
||||
<?= $content ?>
|
||||
<?php endif; ?>
|
||||
{# Page Content #}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Global Scripts -->
|
||||
{# Global Scripts #}
|
||||
<script>
|
||||
// Theme toggle function
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const isDark = html.classList.contains('dark');
|
||||
|
||||
if (isDark) {
|
||||
html.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
html.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle sidebar on mobile
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
sidebar.classList.toggle('open');
|
||||
overlay.classList.toggle('show');
|
||||
// Prevent body scroll when sidebar is open
|
||||
document.body.style.overflow = sidebar.classList.contains('open') ? 'hidden' : '';
|
||||
overlay.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
// Close sidebar (for overlay click and link clicks)
|
||||
|
||||
// Close sidebar on mobile
|
||||
function closeSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebarOverlay');
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('show');
|
||||
document.body.style.overflow = '';
|
||||
overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close sidebar when clicking a link (mobile only)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const sidebarLinks = document.querySelectorAll('#sidebar a');
|
||||
sidebarLinks.forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
if (window.innerWidth < 768) {
|
||||
closeSidebar();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle swipe to close sidebar on mobile
|
||||
let touchStartX = 0;
|
||||
let touchEndX = 0;
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
|
||||
sidebar.addEventListener('touchstart', function(e) {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
}, { passive: true });
|
||||
|
||||
sidebar.addEventListener('touchend', function(e) {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
handleSwipe();
|
||||
}, { passive: true });
|
||||
|
||||
function handleSwipe() {
|
||||
const swipeThreshold = 50;
|
||||
if (touchStartX - touchEndX > swipeThreshold) {
|
||||
// Swiped left - close sidebar
|
||||
closeSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive search placeholder
|
||||
const searchInput = document.getElementById('globalSearchInput');
|
||||
if (searchInput) {
|
||||
function updateSearchPlaceholder() {
|
||||
if (window.innerWidth < 640) {
|
||||
searchInput.placeholder = 'Search...';
|
||||
} else {
|
||||
searchInput.placeholder = 'Search domains or lookup WHOIS...';
|
||||
}
|
||||
}
|
||||
updateSearchPlaceholder();
|
||||
window.addEventListener('resize', updateSearchPlaceholder);
|
||||
}
|
||||
});
|
||||
|
||||
// Close all dropdowns except the one specified
|
||||
function closeOtherDropdowns(exceptId) {
|
||||
function closeOtherDropdowns(except) {
|
||||
['userDropdown', 'notificationsDropdown', 'quickActionsDropdown'].forEach(id => {
|
||||
if (id !== exceptId) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('show');
|
||||
if (id !== except) {
|
||||
const dd = document.getElementById(id);
|
||||
if (dd) dd.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle user dropdown
|
||||
function toggleDropdown() {
|
||||
closeOtherDropdowns('userDropdown');
|
||||
document.getElementById('userDropdown').classList.toggle('show');
|
||||
}
|
||||
|
||||
// Toggle notifications dropdown
|
||||
function toggleNotifications() {
|
||||
closeOtherDropdowns('notificationsDropdown');
|
||||
document.getElementById('notificationsDropdown').classList.toggle('show');
|
||||
}
|
||||
|
||||
// Toggle quick actions dropdown
|
||||
function toggleQuickActions() {
|
||||
closeOtherDropdowns('quickActionsDropdown');
|
||||
document.getElementById('quickActionsDropdown').classList.toggle('show');
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const dropdowns = [
|
||||
{ id: 'userDropdown', trigger: '[onclick="toggleDropdown()"]' },
|
||||
{ id: 'notificationsDropdown', trigger: '[onclick="toggleNotifications()"]' },
|
||||
{ id: 'quickActionsDropdown', trigger: '[onclick="toggleQuickActions()"]' }
|
||||
];
|
||||
|
||||
|
||||
dropdowns.forEach(({ id, trigger }) => {
|
||||
const dd = document.getElementById(id);
|
||||
if (dd && dd.classList.contains('show')) {
|
||||
@@ -344,6 +290,7 @@ if (!isset($appName)) {
|
||||
|
||||
function renderSearchResults(data) {
|
||||
let html = '';
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
|
||||
if (data.domains && data.domains.length > 0) {
|
||||
html += '<div class="p-2">';
|
||||
@@ -360,11 +307,11 @@ if (!isset($appName)) {
|
||||
const colorClass = statusColors[domain.status_color] || 'text-gray-600';
|
||||
|
||||
html += `
|
||||
<a href="/domains/${domain.id}" class="block px-3 py-2 hover:bg-gray-50 rounded-lg transition-colors">
|
||||
<a href="/domains/${domain.id}" class="block px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-gray-900 truncate">${escapeHtml(domain.domain_name)}</p>
|
||||
<p class="text-xs text-gray-500">${escapeHtml(domain.registrar || 'Unknown registrar')}</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">${escapeHtml(domain.domain_name)}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">${escapeHtml(domain.registrar || 'Unknown registrar')}</p>
|
||||
</div>
|
||||
${domain.days_left !== null ? `
|
||||
<div class="ml-3 text-right">
|
||||
@@ -382,11 +329,11 @@ if (!isset($appName)) {
|
||||
// Show WHOIS lookup option if no results and looks like a domain
|
||||
if (data.domains.length === 0 && data.isDomainLike) {
|
||||
html += `
|
||||
<div class="p-4 border-t border-gray-200">
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Domain not in portfolio</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Perform WHOIS lookup for ${escapeHtml(data.query)}</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Domain not in portfolio</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Perform WHOIS lookup for ${escapeHtml(data.query)}</p>
|
||||
</div>
|
||||
<button onclick="window.location.href='/search?q=${encodeURIComponent(data.query)}'" class="px-3 py-1.5 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark">
|
||||
Lookup
|
||||
@@ -395,14 +342,14 @@ if (!isset($appName)) {
|
||||
</div>
|
||||
`;
|
||||
} else if (data.domains.length === 0) {
|
||||
html += '<div class="p-4 text-center text-sm text-gray-500">No results found</div>';
|
||||
html += '<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">No results found</div>';
|
||||
}
|
||||
|
||||
// Add "View all results" link if there are results
|
||||
if (data.domains.length > 0) {
|
||||
html += `
|
||||
<div class="border-t border-gray-200 p-2">
|
||||
<a href="/search?q=${encodeURIComponent(data.query)}" class="block px-3 py-2 text-center text-sm font-medium text-primary hover:bg-gray-50 rounded-lg">
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 p-2">
|
||||
<a href="/search?q=${encodeURIComponent(data.query)}" class="block px-3 py-2 text-center text-sm font-medium text-primary hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg">
|
||||
View all results →
|
||||
</a>
|
||||
</div>
|
||||
@@ -419,11 +366,12 @@ if (!isset($appName)) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Custom Page Scripts (optional) -->
|
||||
<?php if (isset($customScripts)): ?>
|
||||
<script><?= $customScripts ?></script>
|
||||
<?php endif; ?>
|
||||
{# Custom Page Scripts (optional) #}
|
||||
{% if customScripts is defined %}
|
||||
<script>{{ customScripts|raw }}</script>
|
||||
{% endif %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<!-- Toast Notifications Container -->
|
||||
<div id="toast-container" class="fixed bottom-4 right-4 z-[9999] space-y-3 max-w-sm">
|
||||
|
||||
<!-- Success Toast -->
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="toast bg-white border-l-4 border-green-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-check text-green-600 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">Success</p>
|
||||
<p class="text-sm text-gray-600 mt-0.5"><?= htmlspecialchars($_SESSION['success']) ?></p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<?php unset($_SESSION['success']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Error Toast -->
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="toast bg-white border-l-4 border-red-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-times text-red-600 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">Error</p>
|
||||
<p class="text-sm text-gray-600 mt-0.5"><?= htmlspecialchars($_SESSION['error']) ?></p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Warning Toast -->
|
||||
<?php if (isset($_SESSION['warning'])): ?>
|
||||
<div class="toast bg-white border-l-4 border-orange-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-orange-600 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">Warning</p>
|
||||
<p class="text-sm text-gray-600 mt-0.5"><?= htmlspecialchars($_SESSION['warning']) ?></p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<?php unset($_SESSION['warning']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Info Toast -->
|
||||
<?php if (isset($_SESSION['info'])): ?>
|
||||
<div class="toast bg-white border-l-4 border-blue-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-info text-blue-600 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900">Info</p>
|
||||
<p class="text-sm text-gray-600 mt-0.5"><?= htmlspecialchars($_SESSION['info']) ?></p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<?php unset($_SESSION['info']); ?>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Toast Auto-Dismiss Script -->
|
||||
<script>
|
||||
// Auto-dismiss toasts after 5 seconds
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const toasts = document.querySelectorAll('.toast');
|
||||
|
||||
toasts.forEach(toast => {
|
||||
// Add fade-out animation after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
|
||||
// Remove from DOM after animation
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
115
app/Views/layout/messages.twig
Normal file
115
app/Views/layout/messages.twig
Normal file
@@ -0,0 +1,115 @@
|
||||
{# Toast Notifications Container #}
|
||||
<div id="toast-container" class="fixed bottom-4 right-4 z-[9999] space-y-3 max-w-sm">
|
||||
|
||||
{# Success Toast #}
|
||||
{% if flash.success is defined %}
|
||||
<div class="toast bg-white dark:bg-slate-800 border-l-4 border-green-500 rounded-lg shadow-lg dark:shadow-slate-900/50 p-4 flex items-start animate-slide-in">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-check text-green-600 dark:text-green-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Success</p>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-0.5">{{ flash.success }}</p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Error Toast #}
|
||||
{% if flash.error is defined %}
|
||||
<div class="toast bg-white dark:bg-slate-800 border-l-4 border-red-500 rounded-lg shadow-lg dark:shadow-slate-900/50 p-4 flex items-start animate-slide-in">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-times text-red-600 dark:text-red-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Error</p>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-0.5">{{ flash.error }}</p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Warning Toast #}
|
||||
{% if flash.warning is defined %}
|
||||
<div class="toast bg-white dark:bg-slate-800 border-l-4 border-orange-500 rounded-lg shadow-lg dark:shadow-slate-900/50 p-4 flex items-start animate-slide-in">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-orange-600 dark:text-orange-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Warning</p>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-0.5">{{ flash.warning }}</p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Info Toast #}
|
||||
{% if flash.info is defined %}
|
||||
<div class="toast bg-white dark:bg-slate-800 border-l-4 border-blue-500 rounded-lg shadow-lg dark:shadow-slate-900/50 p-4 flex items-start animate-slide-in">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-info text-blue-600 dark:text-blue-400 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Info</p>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-0.5">{{ flash.info }}</p>
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300 transition-colors">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{# Toast Auto-Dismiss Script #}
|
||||
<script>
|
||||
// Auto-dismiss toasts after 5 seconds
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const toasts = document.querySelectorAll('.toast');
|
||||
|
||||
toasts.forEach(toast => {
|
||||
// Add fade-out animation after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
|
||||
// Remove from DOM after animation
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
@@ -1,136 +0,0 @@
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside id="sidebar" class="sidebar fixed left-0 top-0 w-64 bg-gray-900 text-white z-30">
|
||||
<div class="h-full overflow-y-auto flex flex-col">
|
||||
|
||||
<!-- Logo Section -->
|
||||
<div class="px-4 sm:px-5 py-4 border-b border-gray-800 flex items-center justify-between flex-shrink-0">
|
||||
<a href="/" class="flex items-center min-w-0 group">
|
||||
<img src="/assets/logo.svg" alt="Domain Monitor" class="w-9 h-9 mr-3 flex-shrink-0">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-base font-bold text-white truncate group-hover:text-primary transition-colors">Domain Monitor</h1>
|
||||
<p class="text-xs text-gray-500 truncate">Track your domains</p>
|
||||
</div>
|
||||
</a>
|
||||
<!-- Close button for mobile -->
|
||||
<button onclick="closeSidebar()" class="md:hidden w-9 h-9 flex items-center justify-center text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors flex-shrink-0 ml-2">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<nav class="px-4 py-3">
|
||||
<div class="space-y-0.5">
|
||||
<a href="/" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= $_SERVER['REQUEST_URI'] === '/' ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-chart-line text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Dashboard</span>
|
||||
</a>
|
||||
|
||||
<a href="/domains" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/domains') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-globe text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Domains</span>
|
||||
</a>
|
||||
|
||||
<a href="/groups" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/groups') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-bell text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Notification Groups</span>
|
||||
</a>
|
||||
|
||||
<a href="/tld-registry" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/tld-registry') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-database text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">TLD Registry</span>
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] !== 'admin'): ?>
|
||||
<span class="ml-auto text-xs bg-gray-700 px-1.5 py-0.5 rounded text-gray-400">View</span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
|
||||
<a href="/tags" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/tags') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-tags text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Tag Management</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tools Section -->
|
||||
<div class="mt-4 pt-3 border-t border-gray-800">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider px-3 mb-1">Tools</p>
|
||||
<div class="space-y-0.5">
|
||||
<a href="/debug/whois" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/debug/whois') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-search text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">WHOIS Lookup</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Section (Admin Only) -->
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<div class="mt-4 pt-3 border-t border-gray-800">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider px-3 mb-1">System</p>
|
||||
<div class="space-y-0.5">
|
||||
<a href="/settings" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/settings') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-cog text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Settings</span>
|
||||
</a>
|
||||
<a href="/users" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/users') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-users text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Users</span>
|
||||
</a>
|
||||
<a href="/errors" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/errors') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-bug text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Error Logs</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
|
||||
<!-- Quick Stats Cards - Pinned to Bottom -->
|
||||
<div class="mt-auto px-4 pb-3 border-t border-gray-800 pt-3">
|
||||
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wider px-3 mb-2">Quick Stats</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="bg-gray-800 rounded-md p-2.5 border border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-7 h-7 bg-blue-500/20 rounded flex items-center justify-center mr-2.5">
|
||||
<i class="fas fa-globe text-blue-400 text-xs"></i>
|
||||
</div>
|
||||
<span class="text-gray-400 text-xs">Total</span>
|
||||
</div>
|
||||
<span class="text-white font-semibold text-sm"><?= $domainStats['total'] ?? 0 ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-md p-2.5 border border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-7 h-7 bg-orange-500/20 rounded flex items-center justify-center mr-2.5">
|
||||
<i class="fas fa-exclamation-triangle text-orange-400 text-xs"></i>
|
||||
</div>
|
||||
<span class="text-gray-400 text-xs" title="Within <?= $domainStats['expiring_threshold'] ?? 30 ?> days">Expiring</span>
|
||||
</div>
|
||||
<span class="text-orange-400 font-semibold text-sm"><?= $domainStats['expiring_soon'] ?? 0 ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800 rounded-md p-2.5 border border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-7 h-7 bg-green-500/20 rounded flex items-center justify-center mr-2.5">
|
||||
<i class="fas fa-check-circle text-green-400 text-xs"></i>
|
||||
</div>
|
||||
<span class="text-gray-400 text-xs">Active</span>
|
||||
</div>
|
||||
<span class="text-green-400 font-semibold text-sm"><?= $domainStats['active'] ?? 0 ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-3 border-t border-gray-800">
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500">© <?= date('Y') ?> <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="hover:text-blue-300 transition-colors duration-150" title="Visit Domain Monitor on GitHub">Domain Monitor</a></p>
|
||||
<p class="text-xs text-gray-600 mt-0.5">v<?= $appVersion ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
124
app/Views/layout/sidebar.twig
Normal file
124
app/Views/layout/sidebar.twig
Normal file
@@ -0,0 +1,124 @@
|
||||
{# Sidebar Navigation Partial #}
|
||||
<aside id="sidebar" class="sidebar fixed left-0 top-0 w-64 z-30
|
||||
bg-white dark:bg-slate-900
|
||||
border-r border-gray-200 dark:border-slate-800
|
||||
transition-colors duration-200">
|
||||
<div class="h-full overflow-y-auto flex flex-col">
|
||||
|
||||
{# Logo Section #}
|
||||
<div class="h-16 px-4 border-b border-gray-200 dark:border-slate-800 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-3">
|
||||
<img src="{{ base_url }}/assets/logo.svg" alt="{{ appSettings.app_name|default('Domain Monitor') }}" class="h-11 w-auto">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-bold text-gray-800 dark:text-white tracking-tight leading-tight">{{ appSettings.app_name|default('Domain Monitor') }}</span>
|
||||
<span class="text-xs text-gray-400 dark:text-slate-500 font-medium">Track your domains</span>
|
||||
</div>
|
||||
</a>
|
||||
{# Mobile Close Button #}
|
||||
<button onclick="closeSidebar()" class="md:hidden flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition-colors">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Navigation Links #}
|
||||
<nav class="px-3 py-4 flex-1">
|
||||
<div class="space-y-0.5">
|
||||
<a href="/" class="sidebar-link group flex items-center px-3 py-2 rounded-lg transition-all duration-200
|
||||
{{ is_active('/') ? 'bg-blue-50 dark:bg-slate-800 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<i class="fas fa-chart-line text-sm mr-3 w-4 {{ is_active('/') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400' }}"></i>
|
||||
<span class="text-sm">Dashboard</span>
|
||||
</a>
|
||||
|
||||
<a href="/domains" class="sidebar-link group flex items-center px-3 py-2 rounded-lg transition-all duration-200
|
||||
{{ is_active_prefix('/domains') ? 'bg-blue-50 dark:bg-slate-800 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<i class="fas fa-globe text-sm mr-3 w-4 {{ is_active_prefix('/domains') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400' }}"></i>
|
||||
<span class="text-sm">Domains</span>
|
||||
</a>
|
||||
|
||||
<a href="/groups" class="sidebar-link group flex items-center px-3 py-2 rounded-lg transition-all duration-200
|
||||
{{ is_active_prefix('/groups') ? 'bg-blue-50 dark:bg-slate-800 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<i class="fas fa-bell text-sm mr-3 w-4 {{ is_active_prefix('/groups') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400' }}"></i>
|
||||
<span class="text-sm">Notification Groups</span>
|
||||
</a>
|
||||
|
||||
<a href="/tld-registry" class="sidebar-link group flex items-center px-3 py-2 rounded-lg transition-all duration-200
|
||||
{{ is_active_prefix('/tld-registry') ? 'bg-blue-50 dark:bg-slate-800 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<i class="fas fa-database text-sm mr-3 w-4 {{ is_active_prefix('/tld-registry') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400' }}"></i>
|
||||
<span class="text-sm">TLD Registry</span>
|
||||
{% if session.role is defined and session.role != 'admin' %}
|
||||
<span class="ml-auto text-xs bg-gray-200 dark:bg-slate-700 px-1.5 py-0.5 rounded text-gray-500 dark:text-slate-400">View</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
<a href="/tags" class="sidebar-link group flex items-center px-3 py-2 rounded-lg transition-all duration-200
|
||||
{{ is_active_prefix('/tags') ? 'bg-blue-50 dark:bg-slate-800 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<i class="fas fa-tags text-sm mr-3 w-4 {{ is_active_prefix('/tags') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400' }}"></i>
|
||||
<span class="text-sm">Tag Management</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Tools Section #}
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-slate-800">
|
||||
<p class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider px-3 mb-1.5">Tools</p>
|
||||
<div class="space-y-0.5">
|
||||
<a href="/debug/whois" class="sidebar-link group flex items-center px-3 py-2 rounded-lg transition-all duration-200
|
||||
{{ is_active_prefix('/debug/whois') ? 'bg-blue-50 dark:bg-slate-800 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<i class="fas fa-search text-sm mr-3 w-4 {{ is_active_prefix('/debug/whois') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400' }}"></i>
|
||||
<span class="text-sm">WHOIS Lookup</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# System Section (Admin Only) #}
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-slate-800">
|
||||
<p class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider px-3 mb-1.5">System</p>
|
||||
<div class="space-y-0.5">
|
||||
<a href="/settings" class="sidebar-link group flex items-center px-3 py-2 rounded-lg transition-all duration-200
|
||||
{{ is_active_prefix('/settings') ? 'bg-blue-50 dark:bg-slate-800 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<i class="fas fa-cog text-sm mr-3 w-4 {{ is_active_prefix('/settings') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400' }}"></i>
|
||||
<span class="text-sm">Settings</span>
|
||||
</a>
|
||||
<a href="/users" class="sidebar-link group flex items-center px-3 py-2 rounded-lg transition-all duration-200
|
||||
{{ is_active_prefix('/users') ? 'bg-blue-50 dark:bg-slate-800 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<i class="fas fa-users text-sm mr-3 w-4 {{ is_active_prefix('/users') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400' }}"></i>
|
||||
<span class="text-sm">Users</span>
|
||||
</a>
|
||||
<a href="/errors" class="sidebar-link group flex items-center px-3 py-2 rounded-lg transition-all duration-200
|
||||
{{ is_active_prefix('/errors') ? 'bg-blue-50 dark:bg-slate-800 text-blue-700 dark:text-blue-400 font-medium' : 'text-gray-600 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-800 hover:text-gray-900 dark:hover:text-white' }}">
|
||||
<i class="fas fa-bug text-sm mr-3 w-4 {{ is_active_prefix('/errors') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400' }}"></i>
|
||||
<span class="text-sm">Error Logs</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{# Quick Stats - Compact #}
|
||||
<div class="px-3 pb-2 border-t border-gray-200 dark:border-slate-800 pt-3">
|
||||
<div class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider px-2 mb-2">Domain Stats</div>
|
||||
<div class="grid grid-cols-3 gap-1.5 px-1">
|
||||
<div class="bg-gray-50 dark:bg-slate-800 rounded-lg p-2 text-center">
|
||||
<div class="text-base font-bold text-gray-800 dark:text-white">{{ domainStats.total|default(0) }}</div>
|
||||
<div class="text-xs text-gray-400 dark:text-slate-500">Total</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-800 rounded-lg p-2 text-center">
|
||||
<div class="text-base font-bold text-orange-500 dark:text-orange-400">{{ domainStats.expiring_soon|default(0) }}</div>
|
||||
<div class="text-xs text-gray-400 dark:text-slate-500" title="Within {{ domainStats.expiring_threshold|default(30) }} days">Expiring</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-slate-800 rounded-lg p-2 text-center">
|
||||
<div class="text-base font-bold text-emerald-500 dark:text-emerald-400">{{ domainStats.active|default(0) }}</div>
|
||||
<div class="text-xs text-gray-400 dark:text-slate-500">Active</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-slate-800">
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-slate-500">© {{ "now"|date("Y") }} <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="text-gray-500 dark:text-slate-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-150" title="Visit {{ appSettings.app_name|default('Domain Monitor') }} on GitHub">{{ appSettings.app_name|default('Domain Monitor') }}</a></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-600 mt-0.5">v{{ appSettings.app_version|default(appVersion) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1,396 +0,0 @@
|
||||
<!-- Top Navigation Bar -->
|
||||
<!-- Notification data ($recentNotifications, $unreadNotifications) loaded in base.php -->
|
||||
<nav class="bg-white border-b border-gray-200 fixed top-0 left-0 md:left-64 right-0 z-20">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Left: Menu button and Page Header -->
|
||||
<div class="flex items-center min-w-0">
|
||||
<button onclick="toggleSidebar()" class="text-gray-500 hover:text-gray-700 focus:outline-none focus:text-gray-700 md:hidden mr-4">
|
||||
<i class="fas fa-bars text-xl"></i>
|
||||
</button>
|
||||
|
||||
<!-- Page Title & Description -->
|
||||
<div class="hidden md:block">
|
||||
<h2 class="text-xl font-bold text-gray-800 flex items-center">
|
||||
<?php if (isset($pageIcon)): ?>
|
||||
<i class="<?= $pageIcon ?> text-primary mr-2"></i>
|
||||
<?php endif; ?>
|
||||
<?= $pageTitle ?? $title ?? 'Dashboard' ?>
|
||||
</h2>
|
||||
<?php if (isset($pageDescription)): ?>
|
||||
<p class="text-sm text-gray-600 mt-0.5"><?= $pageDescription ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Search Bar -->
|
||||
<div class="flex-1 max-w-2xl mx-2 sm:mx-4 lg:mx-8">
|
||||
<form action="/search" method="GET" class="relative" id="globalSearchForm">
|
||||
<input type="text"
|
||||
name="q"
|
||||
placeholder="Search..."
|
||||
class="w-full pl-9 sm:pl-10 pr-3 sm:pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-sm"
|
||||
id="globalSearchInput"
|
||||
autocomplete="off">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||
|
||||
<!-- Search Results Dropdown -->
|
||||
<div id="searchDropdown" class="hidden absolute top-full left-0 right-0 mt-2 bg-white rounded-lg shadow-xl border border-gray-200 max-h-96 overflow-y-auto z-50">
|
||||
<!-- Loading state -->
|
||||
<div id="searchLoading" class="hidden p-4 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-primary"></i>
|
||||
<p class="text-sm text-gray-600 mt-2">Searching...</p>
|
||||
</div>
|
||||
|
||||
<!-- Results will be inserted here -->
|
||||
<div id="searchResults"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right: Actions & User -->
|
||||
<div class="flex items-center space-x-1 sm:space-x-2">
|
||||
<!-- Update available badge (admin only, when enabled in settings) -->
|
||||
<?php if (!empty($updateBadge['show'])): ?>
|
||||
<a href="/settings#updates" class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition-colors text-xs font-semibold whitespace-nowrap" title="An update is available">
|
||||
<i class="fas fa-cloud-download-alt"></i>
|
||||
<span>Update<?= !empty($updateBadge['label']) ? ' ' . htmlspecialchars($updateBadge['label']) : '' ?></span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Quick Actions Dropdown -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleQuickActions()" title="Quick Actions" class="flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<div id="quickActionsDropdown" class="dropdown-menu absolute right-0 mt-2 w-52 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden py-1">
|
||||
<div class="px-3 py-2 border-b border-gray-100">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Quick Actions</p>
|
||||
</div>
|
||||
<a href="/domains/create" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-blue-50 hover:text-primary transition-colors">
|
||||
<div class="w-7 h-7 bg-blue-50 rounded-md flex items-center justify-center mr-3">
|
||||
<i class="fas fa-globe text-blue-600 text-xs"></i>
|
||||
</div>
|
||||
Add Domain
|
||||
</a>
|
||||
<a href="/groups/create" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-green-50 hover:text-green-700 transition-colors">
|
||||
<div class="w-7 h-7 bg-green-50 rounded-md flex items-center justify-center mr-3">
|
||||
<i class="fas fa-bell text-green-600 text-xs"></i>
|
||||
</div>
|
||||
Create Group
|
||||
</a>
|
||||
<a href="/tags" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-purple-50 hover:text-purple-700 transition-colors">
|
||||
<div class="w-7 h-7 bg-purple-50 rounded-md flex items-center justify-center mr-3">
|
||||
<i class="fas fa-tag text-purple-600 text-xs"></i>
|
||||
</div>
|
||||
Create Tag
|
||||
</a>
|
||||
<a href="/debug/whois" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 transition-colors">
|
||||
<div class="w-7 h-7 bg-indigo-50 rounded-md flex items-center justify-center mr-3">
|
||||
<i class="fas fa-search text-indigo-600 text-xs"></i>
|
||||
</div>
|
||||
WHOIS Lookup
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleNotifications()" title="Notifications" class="relative flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150">
|
||||
<i class="fas fa-bell"></i>
|
||||
<?php if ($unreadNotifications > 0): ?>
|
||||
<span class="absolute top-1 right-1 flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-orange-500"></span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
|
||||
<!-- Notifications Dropdown -->
|
||||
<div id="notificationsDropdown" class="dropdown-menu absolute right-0 mt-2 w-80 sm:w-96 bg-white rounded-lg shadow-xl border border-gray-200 max-h-[32rem] overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Notifications</h3>
|
||||
<?php if ($unreadNotifications > 0): ?>
|
||||
<span id="dropdownHeaderBadge" class="px-2 py-0.5 bg-orange-100 text-orange-700 text-xs font-semibold rounded"><?= $unreadNotifications ?> new</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications List (Scrollable) -->
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<?php if (!empty($recentNotifications)): ?>
|
||||
<?php foreach ($recentNotifications as $notif): ?>
|
||||
<?php
|
||||
// Build the click URL: update_available → settings#updates; domain → domain page; else mark as read only
|
||||
$hasDomain = !empty($notif['domain_id']);
|
||||
if ($notif['type'] === 'update_available') {
|
||||
$notifUrl = '/notifications/' . $notif['id'] . '/mark-read?redirect=settings';
|
||||
} elseif ($hasDomain) {
|
||||
$notifUrl = '/notifications/' . $notif['id'] . '/mark-read?redirect=domain&domain_id=' . $notif['domain_id'];
|
||||
} else {
|
||||
$notifUrl = '/notifications/' . $notif['id'] . '/mark-read';
|
||||
}
|
||||
?>
|
||||
<div class="px-4 py-3 hover:bg-gray-50 border-b border-gray-100 bg-blue-50 transition-colors notification-item" data-id="<?= $notif['id'] ?>">
|
||||
<div class="flex items-start space-x-3">
|
||||
<?php $loginData = $notif['login_data'] ?? null; ?>
|
||||
<?php if ($loginData && $notif['type'] === 'session_failed'): ?>
|
||||
<!-- Failed login notification -->
|
||||
<a href="<?= $notifUrl ?>" class="w-8 h-8 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
<?php if (($loginData['country_code'] ?? 'xx') !== 'xx'): ?>
|
||||
<span class="fi fi-<?= strtolower($loginData['country_code']) ?> text-base rounded-sm"></span>
|
||||
<?php else: ?>
|
||||
<i class="fas fa-shield-alt text-red-600 text-sm"></i>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<a href="<?= $notifUrl ?>" class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-red-700"><?= htmlspecialchars($notif['title']) ?></p>
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full flex-shrink-0"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-0.5">
|
||||
<?= htmlspecialchars(\App\Helpers\LayoutHelper::formatLoginDropdown($loginData)) ?>
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<i class="fas fa-<?= htmlspecialchars($loginData['device_icon'] ?? 'desktop') ?> mr-0.5"></i>
|
||||
<?= htmlspecialchars($loginData['reason'] ?? 'Failed') ?> · <?= $notif['time_ago'] ?>
|
||||
</p>
|
||||
</a>
|
||||
<?php elseif ($loginData && $notif['type'] === 'session_new'): ?>
|
||||
<!-- Session notification with flag icon -->
|
||||
<a href="<?= $notifUrl ?>" class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
<?php if ($loginData['country_code'] !== 'xx'): ?>
|
||||
<span class="fi fi-<?= strtolower($loginData['country_code']) ?> text-base rounded-sm"></span>
|
||||
<?php else: ?>
|
||||
<i class="fas fa-sign-in-alt text-blue-600 text-sm"></i>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<a href="<?= $notifUrl ?>" class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($notif['title']) ?></p>
|
||||
<span class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-0.5">
|
||||
<?= htmlspecialchars(\App\Helpers\LayoutHelper::formatLoginDropdown($loginData)) ?>
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<i class="fas fa-<?= htmlspecialchars($loginData['device_icon'] ?? 'desktop') ?> mr-0.5"></i>
|
||||
<?= htmlspecialchars($loginData['method'] ?? 'Login') ?> · <?= $notif['time_ago'] ?>
|
||||
</p>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<!-- Standard notification -->
|
||||
<a href="<?= $notifUrl ?>" class="w-8 h-8 bg-<?= $notif['color'] ?>-100 rounded-lg flex items-center justify-center flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-<?= $notif['icon'] ?> text-<?= $notif['color'] ?>-600 text-sm"></i>
|
||||
</a>
|
||||
<a href="<?= $notifUrl ?>" class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($notif['title']) ?></p>
|
||||
<span class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-0.5"><?= htmlspecialchars($notif['message']) ?></p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<?= $notif['time_ago'] ?>
|
||||
<?php if ($hasDomain): ?>
|
||||
<span class="text-primary ml-1"><i class="fas fa-external-link-alt text-[10px]"></i> View domain</span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<button onclick="event.stopPropagation(); markNotifRead(<?= $notif['id'] ?>, this)"
|
||||
class="w-7 h-7 flex items-center justify-center text-gray-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors flex-shrink-0"
|
||||
title="Mark as read">
|
||||
<i class="fas fa-check text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<div class="px-4 py-8 text-center">
|
||||
<i class="fas fa-bell-slash text-gray-300 text-3xl mb-2"></i>
|
||||
<p class="text-sm text-gray-600">No new notifications</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">You're all caught up!</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Footer - View All Button -->
|
||||
<div class="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<a href="/notifications" class="block text-center text-sm font-medium text-primary hover:text-primary-dark">
|
||||
View All Notifications
|
||||
<i class="fas fa-arrow-right ml-1 text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="hidden md:block h-8 w-px bg-gray-300"></div>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleDropdown()" class="flex items-center space-x-3 p-2 hover:bg-gray-100 rounded-lg transition-colors duration-150 focus:outline-none">
|
||||
<?php
|
||||
// Get user data for avatar
|
||||
$userModel = new \App\Models\User();
|
||||
$user = $userModel->find($_SESSION['user_id'] ?? 0);
|
||||
$avatar = $user ? \App\Helpers\AvatarHelper::getAvatar($user, 36) : null;
|
||||
?>
|
||||
<?php if ($avatar && ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar')): ?>
|
||||
<img src="<?= htmlspecialchars($avatar['url']) ?>"
|
||||
alt="<?= htmlspecialchars($avatar['alt']) ?>"
|
||||
class="w-9 h-9 rounded-full object-cover"
|
||||
loading="lazy">
|
||||
<?php else: ?>
|
||||
<div class="w-9 h-9 rounded-full bg-primary flex items-center justify-center text-white font-semibold">
|
||||
<?= strtoupper(substr($_SESSION['username'] ?? 'A', 0, 1)) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="hidden lg:block text-left">
|
||||
<p class="text-sm font-medium text-gray-700"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p>
|
||||
<p class="text-xs text-gray-500"><?= htmlspecialchars($_SESSION['email'] ?? '') ?></p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-gray-400 text-xs hidden md:block"></i>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<div id="userDropdown" class="dropdown-menu absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden pb-2">
|
||||
<!-- Welcome Header -->
|
||||
<div class="px-4 py-4 border-b border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100">
|
||||
<div class="text-center">
|
||||
<div class="relative w-12 h-12 mx-auto mb-2">
|
||||
<?php if ($avatar && ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar')): ?>
|
||||
<img src="<?= htmlspecialchars($avatar['url']) ?>"
|
||||
alt="<?= htmlspecialchars($avatar['alt']) ?>"
|
||||
class="w-12 h-12 rounded-full object-cover"
|
||||
loading="lazy">
|
||||
<?php else: ?>
|
||||
<div class="w-12 h-12 rounded-full bg-primary flex items-center justify-center text-white font-bold text-lg">
|
||||
<?= strtoupper(substr($_SESSION['username'] ?? 'A', 0, 1)) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<!-- Online status dot -->
|
||||
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white flex items-center justify-center">
|
||||
<div class="w-2 h-2 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-gray-900">Welcome back!</p>
|
||||
<p class="text-xs text-gray-500 mt-1"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p>
|
||||
<!-- Role indicator -->
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-2 py-1 bg-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-100 text-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-700 text-xs font-medium rounded-full">
|
||||
<i class="fas fa-<?= $_SESSION['role'] === 'admin' ? 'crown' : 'user' ?> mr-1"></i>
|
||||
<?= ucfirst($_SESSION['role'] ?? 'user') ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/profile#profile" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
|
||||
<i class="fas fa-user-circle w-5 text-gray-400 mr-3"></i>
|
||||
My Profile
|
||||
</a>
|
||||
|
||||
<a href="/profile#security" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
|
||||
<i class="fas fa-cog w-5 text-gray-400 mr-3"></i>
|
||||
Account Settings
|
||||
</a>
|
||||
|
||||
<a href="/notifications" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
|
||||
<i class="fas fa-bell w-5 text-gray-400 mr-3"></i>
|
||||
Notifications
|
||||
<?php if ($unreadNotifications > 0): ?>
|
||||
<span id="userDropdownNotifBadge" class="ml-auto px-2 py-0.5 bg-orange-500 text-white text-xs font-bold rounded-full">
|
||||
<?= $unreadNotifications ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
|
||||
<div class="border-t border-gray-200 my-1"></div>
|
||||
|
||||
<a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
|
||||
<i class="fab fa-github w-5 text-gray-400 mr-3"></i>
|
||||
Help & Support
|
||||
<i class="fas fa-external-link-alt ml-auto text-xs text-gray-400"></i>
|
||||
</a>
|
||||
|
||||
<div class="border-t border-gray-200 my-1"></div>
|
||||
|
||||
<a href="/logout" class="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors duration-150">
|
||||
<i class="fas fa-sign-out-alt w-5 mr-3"></i>
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Notification AJAX handler -->
|
||||
<script>
|
||||
function markNotifRead(notifId, btn) {
|
||||
fetch('/notifications/' + notifId + '/mark-read?ajax=1', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Request failed');
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data.success) return;
|
||||
|
||||
const newCount = data.unread_count ?? 0;
|
||||
|
||||
// Remove the notification item from dropdown
|
||||
const item = btn.closest('.notification-item');
|
||||
if (item) {
|
||||
const scrollable = document.querySelector('#notificationsDropdown .max-h-96');
|
||||
const isLast = scrollable && scrollable.querySelectorAll('.notification-item').length <= 1;
|
||||
|
||||
if (isLast && scrollable) {
|
||||
scrollable.style.transition = 'opacity 0.2s';
|
||||
scrollable.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
scrollable.innerHTML = '<div class="px-4 py-8 text-center">' +
|
||||
'<i class="fas fa-bell-slash text-gray-300 text-3xl mb-2"></i>' +
|
||||
'<p class="text-sm text-gray-600">No new notifications</p>' +
|
||||
'<p class="text-xs text-gray-400 mt-0.5">You\'re all caught up!</p>' +
|
||||
'</div>';
|
||||
scrollable.style.opacity = '1';
|
||||
}, 200);
|
||||
} else {
|
||||
item.style.transition = 'opacity 0.2s, max-height 0.3s';
|
||||
item.style.opacity = '0';
|
||||
item.style.maxHeight = '0';
|
||||
item.style.overflow = 'hidden';
|
||||
item.style.padding = '0';
|
||||
item.style.margin = '0';
|
||||
setTimeout(() => item.remove(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Update all badges using server-returned count
|
||||
const headerBadge = document.getElementById('dropdownHeaderBadge');
|
||||
const userBadge = document.getElementById('userDropdownNotifBadge');
|
||||
const bellDot = document.querySelector('[onclick="toggleNotifications()"] .absolute.top-1');
|
||||
|
||||
if (newCount <= 0) {
|
||||
if (headerBadge) headerBadge.remove();
|
||||
if (userBadge) userBadge.remove();
|
||||
if (bellDot) bellDot.remove();
|
||||
} else {
|
||||
if (headerBadge) headerBadge.textContent = newCount + ' new';
|
||||
if (userBadge) userBadge.textContent = newCount;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.location.href = '/notifications/' + notifId + '/mark-read';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
373
app/Views/layout/top-nav.twig
Normal file
373
app/Views/layout/top-nav.twig
Normal file
@@ -0,0 +1,373 @@
|
||||
<!-- Top Navigation Bar -->
|
||||
<nav class="h-16 bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-800 fixed top-0 left-0 md:left-64 right-0 z-20 transition-colors duration-200">
|
||||
<div class="h-full px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-full">
|
||||
<!-- Left: Menu button and Page Header -->
|
||||
<div class="flex items-center min-w-0">
|
||||
<button onclick="toggleSidebar()" class="flex md:hidden items-center justify-center w-10 h-10 -ml-2 mr-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition-colors focus:outline-none">
|
||||
<i class="fas fa-bars text-xl"></i>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{% if pageIcon is defined %}
|
||||
<div class="hidden sm:flex items-center justify-center w-11 h-11 bg-primary/10 dark:bg-primary/20 rounded-xl">
|
||||
<i class="{{ pageIcon }} text-primary text-xl"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg md:text-xl font-bold text-gray-800 dark:text-white truncate">
|
||||
{{ pageTitle|default(title)|default('Dashboard') }}
|
||||
</h2>
|
||||
{% if pageDescription is defined %}
|
||||
<p class="hidden sm:block text-sm text-gray-600 dark:text-slate-400 truncate">{{ pageDescription }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Search Bar -->
|
||||
<div class="flex-1 max-w-md mx-4 lg:mx-6">
|
||||
<form action="/search" method="GET" class="relative hidden md:block" id="globalSearchForm">
|
||||
<input type="text"
|
||||
name="q"
|
||||
placeholder="Search domains or lookup WHOIS..."
|
||||
class="w-full pl-9 pr-3 py-1.5 border border-gray-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-slate-500 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent text-sm transition-colors duration-200"
|
||||
id="globalSearchInput"
|
||||
autocomplete="off">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-sm"></i>
|
||||
|
||||
<div id="searchDropdown" class="hidden absolute top-full left-0 right-0 mt-2 bg-white dark:bg-slate-800 rounded-lg shadow-xl border border-gray-200 dark:border-slate-700 max-h-96 overflow-y-auto z-50">
|
||||
<div id="searchLoading" class="hidden p-4 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-primary"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-2">Searching...</p>
|
||||
</div>
|
||||
<div id="searchResults"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right: Actions & User -->
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if updateBadge.show|default(false) %}
|
||||
<a href="/settings#updates" class="hidden sm:flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-amber-100 dark:bg-amber-500/20 text-amber-800 dark:text-amber-400 hover:bg-amber-200 dark:hover:bg-amber-500/30 transition-colors text-xs font-semibold whitespace-nowrap" title="An update is available">
|
||||
<i class="fas fa-cloud-download-alt"></i>
|
||||
<span>Update{{ updateBadge.label ? ' ' ~ updateBadge.label : '' }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions Dropdown -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleQuickActions()" title="Quick Actions" class="flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition-colors duration-150">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<div id="quickActionsDropdown" class="dropdown-menu absolute right-0 mt-2 w-52 bg-white dark:bg-slate-800 rounded-lg shadow-xl border border-gray-200 dark:border-slate-700 overflow-hidden py-1">
|
||||
<div class="px-3 py-2 border-b border-gray-100 dark:border-slate-700">
|
||||
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Quick Actions</p>
|
||||
</div>
|
||||
<a href="/domains/create" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-blue-50 dark:hover:bg-blue-500/10 hover:text-primary transition-colors">
|
||||
<div class="w-7 h-7 bg-blue-50 dark:bg-blue-500/20 rounded-md flex items-center justify-center mr-3">
|
||||
<i class="fas fa-globe text-blue-600 dark:text-blue-400 text-xs"></i>
|
||||
</div>
|
||||
Add Domain
|
||||
</a>
|
||||
<a href="/groups/create" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-green-50 dark:hover:bg-green-500/10 hover:text-green-700 dark:hover:text-green-400 transition-colors">
|
||||
<div class="w-7 h-7 bg-green-50 dark:bg-green-500/20 rounded-md flex items-center justify-center mr-3">
|
||||
<i class="fas fa-bell text-green-600 dark:text-green-400 text-xs"></i>
|
||||
</div>
|
||||
Create Group
|
||||
</a>
|
||||
<a href="/tags" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-purple-50 dark:hover:bg-purple-500/10 hover:text-purple-700 dark:hover:text-purple-400 transition-colors">
|
||||
<div class="w-7 h-7 bg-purple-50 dark:bg-purple-500/20 rounded-md flex items-center justify-center mr-3">
|
||||
<i class="fas fa-tag text-purple-600 dark:text-purple-400 text-xs"></i>
|
||||
</div>
|
||||
Create Tag
|
||||
</a>
|
||||
<a href="/debug/whois" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 hover:text-indigo-700 dark:hover:text-indigo-400 transition-colors">
|
||||
<div class="w-7 h-7 bg-indigo-50 dark:bg-indigo-500/20 rounded-md flex items-center justify-center mr-3">
|
||||
<i class="fas fa-search text-indigo-600 dark:text-indigo-400 text-xs"></i>
|
||||
</div>
|
||||
WHOIS Lookup
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dark/Light Mode Toggle -->
|
||||
<button onclick="toggleTheme()" id="themeToggle" title="Toggle theme" class="flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition-colors duration-150">
|
||||
<i class="fas fa-moon dark:hidden"></i>
|
||||
<i class="fas fa-sun hidden dark:inline"></i>
|
||||
</button>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleNotifications()" title="Notifications" class="relative flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-slate-400 dark:hover:text-white dark:hover:bg-slate-800 rounded-lg transition-colors duration-150">
|
||||
<i class="fas fa-bell"></i>
|
||||
{% if unreadNotifications > 0 %}
|
||||
<span class="absolute top-1 right-1 flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-orange-500"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
|
||||
<!-- Notifications Dropdown -->
|
||||
<div id="notificationsDropdown" class="dropdown-menu absolute right-0 mt-2 w-80 sm:w-96 bg-white dark:bg-slate-800 rounded-lg shadow-xl border border-gray-200 dark:border-slate-700 max-h-[32rem] overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||
{% if unreadNotifications > 0 %}
|
||||
<span id="dropdownHeaderBadge" class="px-2 py-0.5 bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400 text-xs font-semibold rounded">{{ unreadNotifications }} new</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
{% if recentNotifications is not empty %}
|
||||
{% for notif in recentNotifications %}
|
||||
{% set hasDomain = notif.domain_id is defined and notif.domain_id %}
|
||||
{% if notif.type == 'update_available' %}
|
||||
{% set notifUrl = '/notifications/' ~ notif.id ~ '/mark-read?redirect=settings' %}
|
||||
{% elseif hasDomain %}
|
||||
{% set notifUrl = '/notifications/' ~ notif.id ~ '/mark-read?redirect=domain&domain_id=' ~ notif.domain_id %}
|
||||
{% else %}
|
||||
{% set notifUrl = '/notifications/' ~ notif.id ~ '/mark-read' %}
|
||||
{% endif %}
|
||||
{% set loginData = notif.login_data|default(null) %}
|
||||
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-slate-700 bg-blue-50 dark:bg-blue-900/20 transition-colors notification-item" data-id="{{ notif.id }}">
|
||||
<div class="flex items-start space-x-3">
|
||||
{% if loginData and notif.type == 'session_failed' %}
|
||||
<a href="{{ notifUrl }}" class="w-8 h-8 bg-red-100 dark:bg-red-900/30 rounded-lg flex items-center justify-center flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
{% if (loginData.country_code|default('xx')) != 'xx' %}
|
||||
<span class="fi fi-{{ loginData.country_code|lower }} text-base rounded-sm"></span>
|
||||
{% else %}
|
||||
<i class="fas fa-shield-alt text-red-600 dark:text-red-400 text-sm"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="{{ notifUrl }}" class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-red-700 dark:text-red-400">{{ notif.title }}</p>
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full flex-shrink-0"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">
|
||||
{{ format_login_dropdown(loginData) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||
<i class="fas fa-{{ loginData.device_icon|default('desktop') }} mr-0.5"></i>
|
||||
{{ loginData.reason|default('Failed') }} · {{ notif.time_ago }}
|
||||
</p>
|
||||
</a>
|
||||
{% elseif loginData and notif.type == 'session_new' %}
|
||||
<a href="{{ notifUrl }}" class="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
{% if loginData.country_code != 'xx' %}
|
||||
<span class="fi fi-{{ loginData.country_code|lower }} text-base rounded-sm"></span>
|
||||
{% else %}
|
||||
<i class="fas fa-sign-in-alt text-blue-600 dark:text-blue-400 text-sm"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="{{ notifUrl }}" class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ notif.title }}</p>
|
||||
<span class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">
|
||||
{{ format_login_dropdown(loginData) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||
<i class="fas fa-{{ loginData.device_icon|default('desktop') }} mr-0.5"></i>
|
||||
{{ loginData.method|default('Login') }} · {{ notif.time_ago }}
|
||||
</p>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ notifUrl }}" class="w-8 h-8 bg-{{ notif.color }}-100 dark:bg-{{ notif.color }}-900/30 rounded-lg flex items-center justify-center flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-{{ notif.icon }} text-{{ notif.color }}-600 dark:text-{{ notif.color }}-400 text-sm"></i>
|
||||
</a>
|
||||
<a href="{{ notifUrl }}" class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ notif.title }}</p>
|
||||
<span class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">{{ notif.message }}</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||
{{ notif.time_ago }}
|
||||
{% if hasDomain %}
|
||||
<span class="text-primary ml-1"><i class="fas fa-external-link-alt text-[10px]"></i> View domain</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="event.stopPropagation(); markNotifRead({{ notif.id }}, this)"
|
||||
class="w-7 h-7 flex items-center justify-center text-gray-400 dark:text-slate-500 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-500/10 rounded transition-colors flex-shrink-0"
|
||||
title="Mark as read">
|
||||
<i class="fas fa-check text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="px-4 py-8 text-center">
|
||||
<i class="fas fa-bell-slash text-gray-300 dark:text-slate-600 text-3xl mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">No new notifications</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mt-0.5">You're all caught up!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<a href="/notifications" class="block text-center text-sm font-medium text-primary hover:text-primary-dark">
|
||||
View All Notifications
|
||||
<i class="fas fa-arrow-right ml-1 text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block h-8 w-px bg-gray-300 dark:bg-slate-700"></div>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleDropdown()" class="flex items-center space-x-3 p-2 hover:bg-gray-100 dark:hover:bg-slate-800 rounded-lg transition-colors duration-150 focus:outline-none">
|
||||
{% if avatar and (avatar.type == 'uploaded' or avatar.type == 'gravatar') %}
|
||||
<img src="{{ avatar.url }}"
|
||||
alt="{{ avatar.alt|default('User avatar') }}"
|
||||
class="w-9 h-9 rounded-full object-cover"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="w-9 h-9 rounded-full bg-primary flex items-center justify-center text-white font-semibold">
|
||||
{{ (auth.username|default('A'))|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="hidden lg:block text-left">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-white">{{ auth.fullName|default(auth.username)|default('User') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">{{ session.email|default('') }}</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-gray-400 dark:text-slate-500 text-xs hidden md:block"></i>
|
||||
</button>
|
||||
|
||||
<div id="userDropdown" class="dropdown-menu absolute right-0 mt-2 w-56 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-gray-200 dark:border-slate-700 overflow-hidden pb-2">
|
||||
<div class="px-4 py-4 border-b border-gray-200 dark:border-slate-700 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-slate-900 dark:to-slate-800">
|
||||
<div class="text-center">
|
||||
<div class="relative w-12 h-12 mx-auto mb-2">
|
||||
{% if avatar and (avatar.type == 'uploaded' or avatar.type == 'gravatar') %}
|
||||
<img src="{{ avatar.url }}"
|
||||
alt="{{ avatar.alt|default('User avatar') }}"
|
||||
class="w-12 h-12 rounded-full object-cover"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="w-12 h-12 rounded-full bg-primary flex items-center justify-center text-white font-bold text-lg">
|
||||
{{ (auth.username|default('A'))|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-white dark:border-slate-800 flex items-center justify-center">
|
||||
<div class="w-2 h-2 bg-white rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">Welcome back!</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">{{ auth.fullName|default(auth.username)|default('User') }}</p>
|
||||
<div class="mt-2">
|
||||
{{ role_badge(auth.role|default('user'), 'xs') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/profile#profile" class="flex items-center px-4 py-2 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<i class="fas fa-user-circle w-5 text-gray-400 dark:text-slate-500 mr-3"></i>
|
||||
My Profile
|
||||
</a>
|
||||
|
||||
<a href="/profile#security" class="flex items-center px-4 py-2 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<i class="fas fa-cog w-5 text-gray-400 dark:text-slate-500 mr-3"></i>
|
||||
Account Settings
|
||||
</a>
|
||||
|
||||
<a href="/notifications" class="flex items-center px-4 py-2 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<i class="fas fa-bell w-5 text-gray-400 dark:text-slate-500 mr-3"></i>
|
||||
Notifications
|
||||
{% if unreadNotifications > 0 %}
|
||||
<span id="userDropdownNotifBadge" class="ml-auto px-2 py-0.5 bg-orange-500 text-white text-xs font-bold rounded-full">
|
||||
{{ unreadNotifications }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-slate-700 my-1"></div>
|
||||
|
||||
<a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="flex items-center px-4 py-2 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<i class="fab fa-github w-5 text-gray-400 dark:text-slate-500 mr-3"></i>
|
||||
Help & Support
|
||||
<i class="fas fa-external-link-alt ml-auto text-xs text-gray-400 dark:text-slate-500"></i>
|
||||
</a>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-slate-700 my-1"></div>
|
||||
|
||||
<a href="/logout" class="flex items-center px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors duration-150">
|
||||
<i class="fas fa-sign-out-alt w-5 mr-3"></i>
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
function markNotifRead(notifId, btn) {
|
||||
fetch('/notifications/' + notifId + '/mark-read?ajax=1', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Request failed');
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data.success) return;
|
||||
|
||||
const newCount = data.unread_count ?? 0;
|
||||
|
||||
const item = btn.closest('.notification-item');
|
||||
if (item) {
|
||||
const scrollable = document.querySelector('#notificationsDropdown .max-h-96');
|
||||
const isLast = scrollable && scrollable.querySelectorAll('.notification-item').length <= 1;
|
||||
|
||||
if (isLast && scrollable) {
|
||||
scrollable.style.transition = 'opacity 0.2s';
|
||||
scrollable.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
scrollable.innerHTML = '<div class="px-4 py-8 text-center">' +
|
||||
'<i class="fas fa-bell-slash text-gray-300 dark:text-slate-600 text-3xl mb-2"></i>' +
|
||||
'<p class="text-sm text-gray-600 dark:text-slate-400">No new notifications</p>' +
|
||||
'<p class="text-xs text-gray-400 dark:text-slate-500 mt-0.5">You\'re all caught up!</p>' +
|
||||
'</div>';
|
||||
scrollable.style.opacity = '1';
|
||||
}, 200);
|
||||
} else {
|
||||
item.style.transition = 'opacity 0.2s, max-height 0.3s';
|
||||
item.style.opacity = '0';
|
||||
item.style.maxHeight = '0';
|
||||
item.style.overflow = 'hidden';
|
||||
item.style.padding = '0';
|
||||
item.style.margin = '0';
|
||||
setTimeout(() => item.remove(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
const headerBadge = document.getElementById('dropdownHeaderBadge');
|
||||
const userBadge = document.getElementById('userDropdownNotifBadge');
|
||||
const bellDot = document.querySelector('[onclick="toggleNotifications()"] .absolute.top-1');
|
||||
|
||||
if (newCount <= 0) {
|
||||
if (headerBadge) headerBadge.remove();
|
||||
if (userBadge) userBadge.remove();
|
||||
if (bellDot) bellDot.remove();
|
||||
} else {
|
||||
if (headerBadge) headerBadge.textContent = newCount + ' new';
|
||||
if (userBadge) userBadge.textContent = newCount;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.location.href = '/notifications/' + notifId + '/mark-read';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -1,447 +0,0 @@
|
||||
<?php
|
||||
$title = 'Notifications';
|
||||
$pageTitle = 'Notifications';
|
||||
$pageDescription = 'View and manage your notifications';
|
||||
$pageIcon = 'fas fa-bell';
|
||||
ob_start();
|
||||
|
||||
// Data is passed from the controller
|
||||
$filterType = $filters['type'] ?? '';
|
||||
$filterStatus = $filters['status'] ?? '';
|
||||
$filterDateRange = $filters['date_range'] ?? '';
|
||||
$page = $pagination['current_page'];
|
||||
$totalPages = $pagination['total_pages'];
|
||||
$perPage = $pagination['per_page'];
|
||||
$totalNotifications = $pagination['total'];
|
||||
$offset = $pagination['showing_from'] - 1;
|
||||
?>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<!-- Placeholder for future bulk selection actions -->
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button onclick="markAllAsRead()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-check-double mr-2"></i>
|
||||
Mark All Read
|
||||
</button>
|
||||
<button onclick="clearAll()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash-alt mr-2"></i>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<form method="GET" action="/notifications" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
|
||||
<select name="status" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="">All Notifications</option>
|
||||
<option value="unread" <?= $filterStatus === 'unread' ? 'selected' : '' ?>>Unread Only</option>
|
||||
<option value="read" <?= $filterStatus === 'read' ? 'selected' : '' ?>>Read Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Type</label>
|
||||
<select name="type" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="">All Types</option>
|
||||
<optgroup label="Domain">
|
||||
<option value="domain_expiring" <?= $filterType === 'domain_expiring' ? 'selected' : '' ?>>Domain Expiring</option>
|
||||
<option value="domain_expired" <?= $filterType === 'domain_expired' ? 'selected' : '' ?>>Domain Expired</option>
|
||||
<option value="domain_available" <?= $filterType === 'domain_available' ? 'selected' : '' ?>>Domain Available</option>
|
||||
<option value="domain_registered" <?= $filterType === 'domain_registered' ? 'selected' : '' ?>>Domain Registered</option>
|
||||
<option value="domain_redemption" <?= $filterType === 'domain_redemption' ? 'selected' : '' ?>>Redemption Period</option>
|
||||
<option value="domain_pending_delete" <?= $filterType === 'domain_pending_delete' ? 'selected' : '' ?>>Pending Delete</option>
|
||||
<option value="domain_updated" <?= $filterType === 'domain_updated' ? 'selected' : '' ?>>Domain Updated</option>
|
||||
<option value="whois_failed" <?= $filterType === 'whois_failed' ? 'selected' : '' ?>>WHOIS Failed</option>
|
||||
</optgroup>
|
||||
<optgroup label="System">
|
||||
<option value="session_new" <?= $filterType === 'session_new' ? 'selected' : '' ?>>New Login</option>
|
||||
<option value="session_failed" <?= $filterType === 'session_failed' ? 'selected' : '' ?>>Failed Login</option>
|
||||
<option value="system_welcome" <?= $filterType === 'system_welcome' ? 'selected' : '' ?>>Welcome</option>
|
||||
<option value="system_upgrade" <?= $filterType === 'system_upgrade' ? 'selected' : '' ?>>System Upgrade</option>
|
||||
<option value="update_available" <?= $filterType === 'update_available' ? 'selected' : '' ?>>Update Available</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Range -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Date Range</label>
|
||||
<select name="date_range" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="">All Time</option>
|
||||
<option value="today" <?= $filterDateRange === 'today' ? 'selected' : '' ?>>Today</option>
|
||||
<option value="week" <?= $filterDateRange === 'week' ? 'selected' : '' ?>>This Week</option>
|
||||
<option value="month" <?= $filterDateRange === 'month' ? 'selected' : '' ?>>This Month</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Apply/Reset Buttons -->
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="/notifications" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold text-gray-900"><?= $offset + 1 ?></span> to
|
||||
<span class="font-semibold text-gray-900"><?= min($offset + $perPage, $totalNotifications) ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $totalNotifications ?></span> notification(s)
|
||||
<?php if ($unreadCount > 0): ?>
|
||||
<span class="text-gray-400">•</span>
|
||||
<span class="font-semibold text-blue-600"><?= $unreadCount ?></span> unread
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/notifications" class="flex items-center gap-2">
|
||||
<!-- Preserve current filters -->
|
||||
<input type="hidden" name="status" value="<?= htmlspecialchars($filterStatus) ?>">
|
||||
<input type="hidden" name="type" value="<?= htmlspecialchars($filterType) ?>">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="10" <?= $perPage == 10 ? 'selected' : '' ?>>10</option>
|
||||
<option value="25" <?= $perPage == 25 ? 'selected' : '' ?>>25</option>
|
||||
<option value="50" <?= $perPage == 50 ? 'selected' : '' ?>>50</option>
|
||||
<option value="100" <?= $perPage == 100 ? 'selected' : '' ?>>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Notifications List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<?php if (!empty($notifications)): ?>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<?php foreach ($notifications as $notification): ?>
|
||||
<?php
|
||||
$bgClass = $notification['is_read'] ? '' : 'bg-blue-50';
|
||||
$iconBgClass = "bg-{$notification['color']}-100";
|
||||
$iconTextClass = "text-{$notification['color']}-600";
|
||||
$hasDomain = !empty($notification['domain_id']);
|
||||
$domainUrl = $hasDomain ? '/domains/' . $notification['domain_id'] : null;
|
||||
$clickUrl = null;
|
||||
if ($notification['type'] === 'update_available') {
|
||||
$clickUrl = '/notifications/' . $notification['id'] . '/mark-read?redirect=settings';
|
||||
} elseif ($hasDomain && !$notification['is_read']) {
|
||||
$clickUrl = '/notifications/' . $notification['id'] . '/mark-read?redirect=domain&domain_id=' . $notification['domain_id'];
|
||||
} elseif ($hasDomain) {
|
||||
$clickUrl = $domainUrl;
|
||||
}
|
||||
$loginData = $notification['login_data'] ?? null;
|
||||
$isLogin = ($notification['type'] === 'session_new' && $loginData);
|
||||
$isFailedLogin = ($notification['type'] === 'session_failed' && $loginData);
|
||||
?>
|
||||
<div class="px-4 py-3 hover:bg-gray-50 transition-colors <?= $bgClass ?>">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<?php if ($isFailedLogin): ?>
|
||||
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-shield-alt text-red-600"></i>
|
||||
</div>
|
||||
<?php elseif ($isLogin): ?>
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-sign-in-alt text-blue-600"></i>
|
||||
</div>
|
||||
<?php elseif ($clickUrl): ?>
|
||||
<a href="<?= $clickUrl ?>" class="w-10 h-10 <?= $iconBgClass ?> rounded-lg flex items-center justify-center flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-<?= $notification['icon'] ?> <?= $iconTextClass ?> text-sm"></i>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<div class="w-10 h-10 <?= $iconBgClass ?> rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-<?= $notification['icon'] ?> <?= $iconTextClass ?> text-sm"></i>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<?php if ($clickUrl): ?>
|
||||
<a href="<?= $clickUrl ?>" class="text-sm font-medium text-gray-900 hover:text-primary transition-colors"><?= htmlspecialchars($notification['title']) ?></a>
|
||||
<?php else: ?>
|
||||
<h3 class="text-sm font-medium text-gray-900"><?= htmlspecialchars($notification['title']) ?></h3>
|
||||
<?php endif; ?>
|
||||
<?php if (!$notification['is_read']): ?>
|
||||
<span class="flex h-1.5 w-1.5 relative">
|
||||
<span class="animate-ping absolute inline-flex h-1.5 w-1.5 rounded-full bg-blue-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-blue-500"></span>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<span class="text-xs text-gray-400 ml-auto flex-shrink-0">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<?= $notification['time_ago'] ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if ($isFailedLogin): ?>
|
||||
<!-- Rich failed login details (mirrors successful login layout) -->
|
||||
<div class="mt-1.5 bg-red-50 rounded-lg p-3 border border-red-200">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-xs">
|
||||
<!-- Location -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-map-marker-alt text-red-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">Location:</span>
|
||||
<span class="text-gray-800 font-medium">
|
||||
<?php if (($loginData['country_code'] ?? 'xx') !== 'xx'): ?>
|
||||
<span class="fi fi-<?= strtolower($loginData['country_code']) ?> text-xs mr-0.5 rounded-sm"></span>
|
||||
<?php endif; ?>
|
||||
<?= htmlspecialchars($loginData['location'] ?? 'Unknown') ?>
|
||||
</span>
|
||||
</div>
|
||||
<!-- IP Address -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-network-wired text-blue-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">IP:</span>
|
||||
<span class="text-gray-800 font-medium font-mono text-[11px]"><?= htmlspecialchars($loginData['ip'] ?? 'unknown') ?></span>
|
||||
</div>
|
||||
<!-- Browser -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-globe text-green-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">Browser:</span>
|
||||
<span class="text-gray-800 font-medium"><?= htmlspecialchars($loginData['browser'] ?? 'Unknown') ?></span>
|
||||
</div>
|
||||
<!-- Device -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-<?= htmlspecialchars($loginData['device_icon'] ?? 'desktop') ?> text-purple-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">Device:</span>
|
||||
<span class="text-gray-800 font-medium"><?= htmlspecialchars($loginData['device'] ?? 'Unknown') ?></span>
|
||||
</div>
|
||||
<!-- OS -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-laptop-code text-indigo-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">OS:</span>
|
||||
<span class="text-gray-800 font-medium"><?= htmlspecialchars($loginData['os'] ?? 'Unknown') ?></span>
|
||||
</div>
|
||||
<!-- ISP -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-server text-amber-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">ISP:</span>
|
||||
<span class="text-gray-800 font-medium truncate"><?= htmlspecialchars($loginData['isp'] ?? 'Unknown') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reason (mirrors Method row) -->
|
||||
<div class="mt-2 pt-2 border-t border-red-200 flex items-center gap-1.5 text-xs">
|
||||
<i class="fas fa-exclamation-triangle text-gray-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">Reason:</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-red-100 text-red-700 rounded font-medium text-[11px]"><?= htmlspecialchars($loginData['reason'] ?? 'Unknown') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($isLogin): ?>
|
||||
<!-- Rich login details -->
|
||||
<div class="mt-1.5 bg-gray-50 rounded-lg p-3 border border-gray-100">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-xs">
|
||||
<!-- Location -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-map-marker-alt text-red-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">Location:</span>
|
||||
<span class="text-gray-800 font-medium">
|
||||
<?php if ($loginData['country_code'] !== 'xx'): ?>
|
||||
<span class="fi fi-<?= strtolower($loginData['country_code']) ?> text-xs mr-0.5 rounded-sm"></span>
|
||||
<?php endif; ?>
|
||||
<?= htmlspecialchars($loginData['location'] ?? 'Unknown') ?>
|
||||
</span>
|
||||
</div>
|
||||
<!-- IP Address -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-network-wired text-blue-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">IP:</span>
|
||||
<span class="text-gray-800 font-medium font-mono text-[11px]"><?= htmlspecialchars($loginData['ip'] ?? 'unknown') ?></span>
|
||||
</div>
|
||||
<!-- Browser -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-globe text-green-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">Browser:</span>
|
||||
<span class="text-gray-800 font-medium"><?= htmlspecialchars($loginData['browser'] ?? 'Unknown') ?></span>
|
||||
</div>
|
||||
<!-- Device -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-<?= htmlspecialchars($loginData['device_icon'] ?? 'desktop') ?> text-purple-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">Device:</span>
|
||||
<span class="text-gray-800 font-medium"><?= htmlspecialchars($loginData['device'] ?? 'Unknown') ?></span>
|
||||
</div>
|
||||
<!-- OS -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-laptop-code text-indigo-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">OS:</span>
|
||||
<span class="text-gray-800 font-medium"><?= htmlspecialchars($loginData['os'] ?? 'Unknown') ?></span>
|
||||
</div>
|
||||
<!-- ISP -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-server text-amber-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">ISP:</span>
|
||||
<span class="text-gray-800 font-medium truncate"><?= htmlspecialchars($loginData['isp'] ?? 'Unknown') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Login method -->
|
||||
<div class="mt-2 pt-2 border-t border-gray-200 flex items-center gap-1.5 text-xs">
|
||||
<i class="fas fa-key text-gray-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500">Method:</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded font-medium text-[11px]"><?= htmlspecialchars($loginData['method'] ?? 'Login') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- Standard notification message -->
|
||||
<p class="text-xs text-gray-600 mt-0.5"><?= htmlspecialchars($notification['message']) ?></p>
|
||||
<?php if ($hasDomain && $clickUrl): ?>
|
||||
<a href="<?= $clickUrl ?>" class="text-xs text-primary mt-0.5 hover:underline inline-block">
|
||||
<i class="fas fa-external-link-alt text-[10px] mr-1"></i>View domain
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 ml-2 flex-shrink-0">
|
||||
<?php if (!$notification['is_read']): ?>
|
||||
<a href="/notifications/<?= $notification['id'] ?>/mark-read" class="w-7 h-7 flex items-center justify-center text-gray-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors" title="Mark as read">
|
||||
<i class="fas fa-check text-xs"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<form method="POST" action="/notifications/<?= $notification['id'] ?>/delete" class="inline" onsubmit="return confirm('Delete this notification?')">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="w-7 h-7 flex items-center justify-center text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors" title="Delete">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
<!-- Empty State -->
|
||||
<div class="p-12 text-center">
|
||||
<i class="fas fa-bell-slash text-gray-300 text-4xl mb-3"></i>
|
||||
<p class="text-sm text-gray-600">No notifications found</p>
|
||||
<p class="text-xs text-gray-400 mt-1">Try adjusting your filters</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Page Info -->
|
||||
<div class="text-sm text-gray-600">
|
||||
Page <span class="font-semibold text-gray-900"><?= $page ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $totalPages ?></span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
<?php
|
||||
// Helper function to build pagination URL
|
||||
function paginationUrl($page, $status, $type) {
|
||||
$params = $_GET;
|
||||
$params['page'] = $page;
|
||||
if ($status) $params['status'] = $status;
|
||||
if ($type) $params['type'] = $type;
|
||||
return '/notifications?' . http_build_query($params);
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- First Page -->
|
||||
<?php if ($page > 1): ?>
|
||||
<a href="<?= paginationUrl(1, $filterStatus, $filterType) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Previous Page -->
|
||||
<?php if ($page > 1): ?>
|
||||
<a href="<?= paginationUrl($page - 1, $filterStatus, $filterType) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Page Numbers -->
|
||||
<?php
|
||||
$range = 2; // Show 2 pages on each side of current page
|
||||
$start = max(1, $page - $range);
|
||||
$end = min($totalPages, $page + $range);
|
||||
|
||||
// Show first page + ellipsis if needed
|
||||
if ($start > 1) {
|
||||
echo '<a href="' . paginationUrl(1, $filterStatus, $filterType) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
|
||||
if ($start > 2) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Page numbers
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
if ($i == $page) {
|
||||
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
|
||||
} else {
|
||||
echo '<a href="' . paginationUrl($i, $filterStatus, $filterType) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
// Show last page + ellipsis if needed
|
||||
if ($end < $totalPages) {
|
||||
if ($end < $totalPages - 1) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
echo '<a href="' . paginationUrl($totalPages, $filterStatus, $filterType) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Next Page -->
|
||||
<?php if ($page < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($page + 1, $filterStatus, $filterType) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Last Page -->
|
||||
<?php if ($page < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($totalPages, $filterStatus, $filterType) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Hidden form for clear all -->
|
||||
<form id="clearAllForm" method="POST" action="/notifications/clear-all" class="hidden">
|
||||
<?= csrf_field() ?>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function markAllAsRead() {
|
||||
if (confirm('Mark all notifications as read?')) {
|
||||
window.location.href = '/notifications/mark-all-read';
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (confirm('Clear all notifications? This action cannot be undone.')) {
|
||||
document.getElementById('clearAllForm').submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
|
||||
422
app/Views/notifications/index.twig
Normal file
422
app/Views/notifications/index.twig
Normal file
@@ -0,0 +1,422 @@
|
||||
{% extends "layout/base.twig" %}
|
||||
|
||||
{% set title = 'Notifications' %}
|
||||
{% set pageTitle = 'Notifications' %}
|
||||
{% set pageDescription = 'View and manage your notifications' %}
|
||||
{% set pageIcon = 'fas fa-bell' %}
|
||||
|
||||
{% block content %}
|
||||
{% set filterType = filters.type ?? '' %}
|
||||
{% set filterStatus = filters.status ?? '' %}
|
||||
{% set filterDateRange = filters.date_range ?? '' %}
|
||||
{% set page = pagination.current_page %}
|
||||
{% set totalPages = pagination.total_pages %}
|
||||
{% set perPage = pagination.per_page %}
|
||||
{% set totalNotifications = pagination.total %}
|
||||
{% set offset = pagination.showing_from - 1 %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button onclick="markAllAsRead()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-check-double mr-2"></i>
|
||||
Mark All Read
|
||||
</button>
|
||||
<button onclick="clearAll()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash-alt mr-2"></i>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 mb-4">
|
||||
<form method="GET" action="/notifications" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Status</label>
|
||||
<select name="status" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">All Notifications</option>
|
||||
<option value="unread" {{ filterStatus == 'unread' ? 'selected' : '' }}>Unread Only</option>
|
||||
<option value="read" {{ filterStatus == 'read' ? 'selected' : '' }}>Read Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Type</label>
|
||||
<select name="type" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">All Types</option>
|
||||
<optgroup label="Domain">
|
||||
<option value="domain_expiring" {{ filterType == 'domain_expiring' ? 'selected' : '' }}>Domain Expiring</option>
|
||||
<option value="domain_expired" {{ filterType == 'domain_expired' ? 'selected' : '' }}>Domain Expired</option>
|
||||
<option value="domain_available" {{ filterType == 'domain_available' ? 'selected' : '' }}>Domain Available</option>
|
||||
<option value="domain_registered" {{ filterType == 'domain_registered' ? 'selected' : '' }}>Domain Registered</option>
|
||||
<option value="domain_redemption" {{ filterType == 'domain_redemption' ? 'selected' : '' }}>Redemption Period</option>
|
||||
<option value="domain_pending_delete" {{ filterType == 'domain_pending_delete' ? 'selected' : '' }}>Pending Delete</option>
|
||||
<option value="domain_updated" {{ filterType == 'domain_updated' ? 'selected' : '' }}>Domain Updated</option>
|
||||
<option value="whois_failed" {{ filterType == 'whois_failed' ? 'selected' : '' }}>WHOIS Failed</option>
|
||||
</optgroup>
|
||||
<optgroup label="System">
|
||||
<option value="session_new" {{ filterType == 'session_new' ? 'selected' : '' }}>New Login</option>
|
||||
<option value="session_failed" {{ filterType == 'session_failed' ? 'selected' : '' }}>Failed Login</option>
|
||||
<option value="system_welcome" {{ filterType == 'system_welcome' ? 'selected' : '' }}>Welcome</option>
|
||||
<option value="system_upgrade" {{ filterType == 'system_upgrade' ? 'selected' : '' }}>System Upgrade</option>
|
||||
<option value="update_available" {{ filterType == 'update_available' ? 'selected' : '' }}>Update Available</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Range -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Date Range</label>
|
||||
<select name="date_range" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">All Time</option>
|
||||
<option value="today" {{ filterDateRange == 'today' ? 'selected' : '' }}>Today</option>
|
||||
<option value="week" {{ filterDateRange == 'week' ? 'selected' : '' }}>This Week</option>
|
||||
<option value="month" {{ filterDateRange == 'month' ? 'selected' : '' }}>This Month</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Apply/Reset Buttons -->
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="/notifications" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ offset + 1 }}</span> to
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ min(offset + perPage, totalNotifications) }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ totalNotifications }}</span> notification(s)
|
||||
{% if unreadCount > 0 %}
|
||||
<span class="text-gray-400 dark:text-slate-500">•</span>
|
||||
<span class="font-semibold text-blue-600">{{ unreadCount }}</span> unread
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/notifications" class="flex items-center gap-2">
|
||||
<input type="hidden" name="status" value="{{ filterStatus }}">
|
||||
<input type="hidden" name="type" value="{{ filterType }}">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600 dark:text-slate-400">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="10" {{ perPage == 10 ? 'selected' : '' }}>10</option>
|
||||
<option value="25" {{ perPage == 25 ? 'selected' : '' }}>25</option>
|
||||
<option value="50" {{ perPage == 50 ? 'selected' : '' }}>50</option>
|
||||
<option value="100" {{ perPage == 100 ? 'selected' : '' }}>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Notifications List -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
{% if notifications is not empty %}
|
||||
<div class="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{% for notification in notifications %}
|
||||
{% set bgClass = notification.is_read ? '' : 'bg-blue-50 dark:bg-blue-500/10' %}
|
||||
{% set iconBgClass = 'bg-' ~ notification.color ~ '-100' %}
|
||||
{% set iconTextClass = 'text-' ~ notification.color ~ '-600' %}
|
||||
{% set hasDomain = notification.domain_id is not empty %}
|
||||
{% set domainUrl = hasDomain ? '/domains/' ~ notification.domain_id : null %}
|
||||
{% set clickUrl = null %}
|
||||
{% if notification.type == 'update_available' %}
|
||||
{% set clickUrl = '/notifications/' ~ notification.id ~ '/mark-read?redirect=settings' %}
|
||||
{% elseif hasDomain and not notification.is_read %}
|
||||
{% set clickUrl = '/notifications/' ~ notification.id ~ '/mark-read?redirect=domain&domain_id=' ~ notification.domain_id %}
|
||||
{% elseif hasDomain %}
|
||||
{% set clickUrl = domainUrl %}
|
||||
{% endif %}
|
||||
{% set loginData = notification.login_data ?? null %}
|
||||
{% set isLogin = (notification.type == 'session_new' and loginData) %}
|
||||
{% set isFailedLogin = (notification.type == 'session_failed' and loginData) %}
|
||||
|
||||
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors {{ bgClass }}">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
{% if isFailedLogin %}
|
||||
<div class="w-10 h-10 bg-red-100 dark:bg-red-500/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-shield-alt text-red-600"></i>
|
||||
</div>
|
||||
{% elseif isLogin %}
|
||||
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-500/10 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-sign-in-alt text-blue-600"></i>
|
||||
</div>
|
||||
{% elseif clickUrl %}
|
||||
<a href="{{ clickUrl }}" class="w-10 h-10 {{ iconBgClass }} rounded-lg flex items-center justify-center flex-shrink-0 hover:opacity-80 transition-opacity">
|
||||
<i class="fas fa-{{ notification.icon }} {{ iconTextClass }} text-sm"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="w-10 h-10 {{ iconBgClass }} rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-{{ notification.icon }} {{ iconTextClass }} text-sm"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if clickUrl %}
|
||||
<a href="{{ clickUrl }}" class="text-sm font-medium text-gray-900 dark:text-white hover:text-primary transition-colors">{{ notification.title }}</a>
|
||||
{% else %}
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{{ notification.title }}</h3>
|
||||
{% endif %}
|
||||
{% if not notification.is_read %}
|
||||
<span class="flex h-1.5 w-1.5 relative">
|
||||
<span class="animate-ping absolute inline-flex h-1.5 w-1.5 rounded-full bg-blue-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-blue-500"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="text-xs text-gray-400 dark:text-slate-500 ml-auto flex-shrink-0">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
{{ notification.time_ago }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if isFailedLogin %}
|
||||
<!-- Rich failed login details -->
|
||||
<div class="mt-1.5 bg-red-50 dark:bg-red-500/10 rounded-lg p-3 border border-red-200 dark:border-red-500/20">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-xs">
|
||||
<!-- Location -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-map-marker-alt text-red-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Location:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium">
|
||||
{% if (loginData.country_code ?? 'xx') != 'xx' %}
|
||||
<span class="fi fi-{{ loginData.country_code|lower }} text-xs mr-0.5 rounded-sm"></span>
|
||||
{% endif %}
|
||||
{{ loginData.location ?? 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- IP Address -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-network-wired text-blue-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">IP:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium font-mono text-[11px]">{{ loginData.ip ?? 'unknown' }}</span>
|
||||
</div>
|
||||
<!-- Browser -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-globe text-green-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Browser:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium">{{ loginData.browser ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
<!-- Device -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-{{ loginData.device_icon ?? 'desktop' }} text-purple-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Device:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium">{{ loginData.device ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
<!-- OS -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-laptop-code text-indigo-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">OS:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium">{{ loginData.os ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
<!-- ISP -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-server text-amber-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">ISP:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium truncate">{{ loginData.isp ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reason -->
|
||||
<div class="mt-2 pt-2 border-t border-red-200 dark:border-red-500/20 flex items-center gap-1.5 text-xs">
|
||||
<i class="fas fa-exclamation-triangle text-gray-400 dark:text-slate-500 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Reason:</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 rounded font-medium text-[11px]">{{ loginData.reason ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% elseif isLogin %}
|
||||
<!-- Rich login details -->
|
||||
<div class="mt-1.5 bg-gray-50 dark:bg-slate-700 rounded-lg p-3 border border-gray-100 dark:border-slate-600">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-x-4 gap-y-2 text-xs">
|
||||
<!-- Location -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-map-marker-alt text-red-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Location:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium">
|
||||
{% if loginData.country_code != 'xx' %}
|
||||
<span class="fi fi-{{ loginData.country_code|lower }} text-xs mr-0.5 rounded-sm"></span>
|
||||
{% endif %}
|
||||
{{ loginData.location ?? 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- IP Address -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-network-wired text-blue-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">IP:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium font-mono text-[11px]">{{ loginData.ip ?? 'unknown' }}</span>
|
||||
</div>
|
||||
<!-- Browser -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-globe text-green-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Browser:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium">{{ loginData.browser ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
<!-- Device -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-{{ loginData.device_icon ?? 'desktop' }} text-purple-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Device:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium">{{ loginData.device ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
<!-- OS -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-laptop-code text-indigo-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">OS:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium">{{ loginData.os ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
<!-- ISP -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<i class="fas fa-server text-amber-400 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">ISP:</span>
|
||||
<span class="text-gray-800 dark:text-slate-200 font-medium truncate">{{ loginData.isp ?? 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Login method -->
|
||||
<div class="mt-2 pt-2 border-t border-gray-200 dark:border-slate-600 flex items-center gap-1.5 text-xs">
|
||||
<i class="fas fa-key text-gray-400 dark:text-slate-500 w-3.5 text-center"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">Method:</span>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 rounded font-medium text-[11px]">{{ loginData.method ?? 'Login' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Standard notification message -->
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">{{ notification.message }}</p>
|
||||
{% if hasDomain and clickUrl %}
|
||||
<a href="{{ clickUrl }}" class="text-xs text-primary mt-0.5 hover:underline inline-block">
|
||||
<i class="fas fa-external-link-alt text-[10px] mr-1"></i>View domain
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 ml-2 flex-shrink-0">
|
||||
{% if not notification.is_read %}
|
||||
<a href="/notifications/{{ notification.id }}/mark-read" class="w-7 h-7 flex items-center justify-center text-gray-400 dark:text-slate-500 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-500/10 rounded transition-colors" title="Mark as read">
|
||||
<i class="fas fa-check text-xs"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<form method="POST" action="/notifications/{{ notification.id }}/delete" class="inline" onsubmit="return confirm('Delete this notification?')">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="w-7 h-7 flex items-center justify-center text-gray-400 dark:text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded transition-colors" title="Delete">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="p-12 text-center">
|
||||
<i class="fas fa-bell-slash text-gray-300 dark:text-slate-600 text-4xl mb-3"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">No notifications found</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 mt-1">Try adjusting your filters</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{% if totalPages > 1 %}
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Page Info -->
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Page <span class="font-semibold text-gray-900 dark:text-white">{{ page }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ totalPages }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- First Page -->
|
||||
{% if page > 1 %}
|
||||
<a href="{{ pagination_url(1, filters, perPage) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Previous Page -->
|
||||
{% if page > 1 %}
|
||||
<a href="{{ pagination_url(page - 1, filters, perPage) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page Numbers -->
|
||||
{% set range = 2 %}
|
||||
{% set startPage = max(1, page - range) %}
|
||||
{% set endPage = min(totalPages, page + range) %}
|
||||
|
||||
{% if startPage > 1 %}
|
||||
<a href="{{ pagination_url(1, filters, perPage) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">1</a>
|
||||
{% if startPage > 2 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for i in startPage..endPage %}
|
||||
{% if i == page %}
|
||||
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">{{ i }}</span>
|
||||
{% else %}
|
||||
<a href="{{ pagination_url(i, filters, perPage) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ i }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if endPage < totalPages %}
|
||||
{% if endPage < totalPages - 1 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
<a href="{{ pagination_url(totalPages, filters, perPage) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ totalPages }}</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Next Page -->
|
||||
{% if page < totalPages %}
|
||||
<a href="{{ pagination_url(page + 1, filters, perPage) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Last Page -->
|
||||
{% if page < totalPages %}
|
||||
<a href="{{ pagination_url(totalPages, filters, perPage) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Hidden form for clear all -->
|
||||
<form id="clearAllForm" method="POST" action="/notifications/clear-all" class="hidden">
|
||||
{{ csrf_field() }}
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function markAllAsRead() {
|
||||
if (confirm('Mark all notifications as read?')) {
|
||||
window.location.href = '/notifications/mark-all-read';
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (confirm('Clear all notifications? This action cannot be undone.')) {
|
||||
document.getElementById('clearAllForm').submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,70 +1,59 @@
|
||||
<?php
|
||||
$title = 'My Profile';
|
||||
$pageTitle = 'My Profile';
|
||||
$pageDescription = 'Manage your account settings and preferences';
|
||||
$pageIcon = 'fas fa-user-circle';
|
||||
ob_start();
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
// Get 2FA status
|
||||
$twoFactorStatus = $userModel->getTwoFactorStatus($user['id']);
|
||||
$twoFactorService = new \App\Services\TwoFactorService();
|
||||
$twoFactorPolicy = $twoFactorService->getTwoFactorPolicy();
|
||||
|
||||
// Get avatar data
|
||||
$avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
?>
|
||||
{% set title = 'My Profile' %}
|
||||
{% set pageTitle = 'My Profile' %}
|
||||
{% set pageDescription = 'Manage your account settings and preferences' %}
|
||||
{% set pageIcon = 'fas fa-user-circle' %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Main Profile Layout -->
|
||||
<div class="grid grid-cols-12 gap-6">
|
||||
<!-- Sidebar Navigation -->
|
||||
<div class="col-span-12 lg:col-span-3">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden sticky top-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden sticky top-6">
|
||||
<!-- User Info Section -->
|
||||
<div class="p-6 border-b border-gray-200 bg-gray-50">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="relative">
|
||||
<?php if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar'): ?>
|
||||
<img src="<?= htmlspecialchars($avatar['url']) ?>"
|
||||
alt="<?= htmlspecialchars($avatar['alt']) ?>"
|
||||
class="w-20 h-20 rounded-full object-cover border-2 border-white shadow-sm"
|
||||
{% if avatar.type == 'uploaded' or avatar.type == 'gravatar' %}
|
||||
<img src="{{ avatar.url }}"
|
||||
alt="{{ avatar.alt }}"
|
||||
class="w-20 h-20 rounded-full object-cover border-2 border-white dark:border-slate-700 shadow-sm"
|
||||
loading="lazy">
|
||||
<?php else: ?>
|
||||
<div class="w-20 h-20 rounded-full bg-primary flex items-center justify-center text-white text-2xl font-bold border-2 border-white shadow-sm">
|
||||
<?= $avatar['initials'] ?>
|
||||
{% else %}
|
||||
<div class="w-20 h-20 rounded-full bg-primary flex items-center justify-center text-white text-2xl font-bold border-2 border-white dark:border-slate-700 shadow-sm">
|
||||
{{ avatar.initials }}
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<!-- Avatar type indicator -->
|
||||
<div class="absolute -bottom-1 -right-1 w-6 h-6 bg-white rounded-full border-2 border-gray-200 flex items-center justify-center">
|
||||
<?php if ($avatar['type'] === 'uploaded'): ?>
|
||||
<i class="fas fa-image text-xs text-blue-600" title="Uploaded avatar"></i>
|
||||
<?php elseif ($avatar['type'] === 'gravatar'): ?>
|
||||
<i class="fas fa-globe text-xs text-green-600" title="Gravatar"></i>
|
||||
<?php else: ?>
|
||||
<i class="fas fa-user text-xs text-gray-500" title="Initials"></i>
|
||||
<?php endif; ?>
|
||||
<div class="absolute -bottom-1 -right-1 w-6 h-6 bg-white dark:bg-slate-800 rounded-full border-2 border-gray-200 dark:border-slate-600 flex items-center justify-center">
|
||||
{% if avatar.type == 'uploaded' %}
|
||||
<i class="fas fa-image text-xs text-blue-600 dark:text-blue-400" title="Uploaded avatar"></i>
|
||||
{% elseif avatar.type == 'gravatar' %}
|
||||
<i class="fas fa-globe text-xs text-green-600 dark:text-green-400" title="Gravatar"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-user text-xs text-gray-500 dark:text-slate-400" title="Initials"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="mt-4 text-base font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></h3>
|
||||
<p class="text-sm text-gray-500 mt-1">@<?= htmlspecialchars($user['username'] ?? '') ?></p>
|
||||
<h3 class="mt-4 text-base font-semibold text-gray-900 dark:text-white">{{ user.full_name|default(user.username) }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400 mt-1">@{{ user.username|default('') }}</p>
|
||||
|
||||
<!-- Role Badge -->
|
||||
<span class="inline-flex items-center mt-3 px-2.5 py-1 bg-<?= $user['role'] === 'admin' ? 'amber' : 'blue' ?>-100 text-<?= $user['role'] === 'admin' ? 'amber' : 'blue' ?>-800 text-xs font-semibold rounded border border-<?= $user['role'] === 'admin' ? 'amber' : 'blue' ?>-200">
|
||||
<i class="fas fa-<?= $user['role'] === 'admin' ? 'crown' : 'user' ?> mr-1.5"></i>
|
||||
<?= ucfirst($user['role'] ?? 'user') ?>
|
||||
</span>
|
||||
<div class="mt-3">{{ role_badge(user.role|default('user')) }}</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 gap-3 mt-4 w-full">
|
||||
<div class="bg-white rounded-lg p-2 border border-gray-200">
|
||||
<div class="text-xs text-gray-500">Member Since</div>
|
||||
<div class="text-xs font-semibold text-gray-900 mt-0.5">
|
||||
<?= date('M Y', strtotime($user['created_at'] ?? 'now')) ?>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-2 border border-gray-200 dark:border-slate-600">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">Member Since</div>
|
||||
<div class="text-xs font-semibold text-gray-900 dark:text-white mt-0.5">
|
||||
{{ (user.created_at|default('now'))|date('M Y') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-2 border border-gray-200">
|
||||
<div class="text-xs text-gray-500">Status</div>
|
||||
<div class="text-xs font-semibold text-green-600 mt-0.5">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-2 border border-gray-200 dark:border-slate-600">
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">Status</div>
|
||||
<div class="text-xs font-semibold text-green-600 dark:text-green-400 mt-0.5">
|
||||
<i class="fas fa-circle text-xs"></i> Active
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,13 +80,13 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
<span>Active Sessions</span>
|
||||
</button>
|
||||
|
||||
<?php if ($user['role'] !== 'admin'): ?>
|
||||
<hr class="my-3 border-gray-200">
|
||||
<button onclick="showSection('danger')" id="nav-danger" class="nav-item w-full flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors text-red-600 hover:bg-red-50">
|
||||
{% if user.role != 'admin' %}
|
||||
<hr class="my-3 border-gray-200 dark:border-slate-700">
|
||||
<button onclick="showSection('danger')" id="nav-danger" class="nav-item w-full flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10">
|
||||
<i class="fas fa-exclamation-triangle w-5 mr-3 text-sm"></i>
|
||||
<span>Danger Zone</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,38 +96,38 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
|
||||
<!-- Profile Information Section -->
|
||||
<div id="section-profile" class="content-section">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Profile Information</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Update your personal details and account information</p>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Profile Information</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Update your personal details and account information</p>
|
||||
</div>
|
||||
|
||||
<!-- Avatar Upload Section -->
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-3">Profile Picture</h4>
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-3">Profile Picture</h4>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Current Avatar Display -->
|
||||
<div class="relative">
|
||||
<?php if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar'): ?>
|
||||
<img src="<?= htmlspecialchars($avatar['url']) ?>"
|
||||
alt="<?= htmlspecialchars($avatar['alt']) ?>"
|
||||
class="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
|
||||
{% if avatar.type == 'uploaded' or avatar.type == 'gravatar' %}
|
||||
<img src="{{ avatar.url }}"
|
||||
alt="{{ avatar.alt }}"
|
||||
class="w-16 h-16 rounded-full object-cover border-2 border-gray-200 dark:border-slate-600"
|
||||
loading="lazy">
|
||||
<?php else: ?>
|
||||
<div class="w-16 h-16 rounded-full bg-primary flex items-center justify-center text-white text-lg font-bold border-2 border-gray-200">
|
||||
<?= $avatar['initials'] ?>
|
||||
{% else %}
|
||||
<div class="w-16 h-16 rounded-full bg-primary flex items-center justify-center text-white text-lg font-bold border-2 border-gray-200 dark:border-slate-600">
|
||||
{{ avatar.initials }}
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<!-- Avatar type indicator -->
|
||||
<div class="absolute -bottom-1 -right-1 w-5 h-5 bg-white rounded-full border-2 border-gray-200 flex items-center justify-center">
|
||||
<?php if ($avatar['type'] === 'uploaded'): ?>
|
||||
<i class="fas fa-image text-xs text-blue-600" title="Uploaded avatar"></i>
|
||||
<?php elseif ($avatar['type'] === 'gravatar'): ?>
|
||||
<i class="fas fa-globe text-xs text-green-600" title="Gravatar"></i>
|
||||
<?php else: ?>
|
||||
<i class="fas fa-user text-xs text-gray-500" title="Initials"></i>
|
||||
<?php endif; ?>
|
||||
<div class="absolute -bottom-1 -right-1 w-5 h-5 bg-white dark:bg-slate-800 rounded-full border-2 border-gray-200 dark:border-slate-600 flex items-center justify-center">
|
||||
{% if avatar.type == 'uploaded' %}
|
||||
<i class="fas fa-image text-xs text-blue-600 dark:text-blue-400" title="Uploaded avatar"></i>
|
||||
{% elseif avatar.type == 'gravatar' %}
|
||||
<i class="fas fa-globe text-xs text-green-600 dark:text-green-400" title="Gravatar"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-user text-xs text-gray-500 dark:text-slate-400" title="Initials"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +136,7 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
<div class="space-y-2">
|
||||
<!-- Upload Form -->
|
||||
<form method="POST" action="/profile/upload-avatar" enctype="multipart/form-data" class="inline-block">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="file"
|
||||
id="avatar"
|
||||
@@ -156,7 +145,7 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
class="hidden"
|
||||
onchange="this.form.submit()">
|
||||
<label for="avatar"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 cursor-pointer">
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm font-medium text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 cursor-pointer">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Upload New
|
||||
</label>
|
||||
@@ -164,77 +153,77 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
</form>
|
||||
|
||||
<!-- Delete Avatar Button -->
|
||||
<?php if ($avatar['type'] === 'uploaded'): ?>
|
||||
{% if avatar.type == 'uploaded' %}
|
||||
<form method="POST" action="/profile/delete-avatar" class="inline-block ml-2">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-3 py-2 border border-red-300 rounded-lg text-sm font-medium text-red-700 bg-white hover:bg-red-50"
|
||||
class="inline-flex items-center px-3 py-2 border border-red-300 dark:border-red-500/30 rounded-lg text-sm font-medium text-red-700 dark:text-red-400 bg-white dark:bg-slate-700 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||||
onclick="return confirm('Are you sure you want to remove your avatar?')">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Avatar Info -->
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
<?php if ($avatar['type'] === 'uploaded'): ?>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-slate-400">
|
||||
{% if avatar.type == 'uploaded' %}
|
||||
Using uploaded image
|
||||
<?php elseif ($avatar['type'] === 'gravatar'): ?>
|
||||
Using Gravatar from <?= htmlspecialchars($user['email'] ?? '') ?>
|
||||
<?php else: ?>
|
||||
{% elseif avatar.type == 'gravatar' %}
|
||||
Using Gravatar from {{ user.email|default('') }}
|
||||
{% else %}
|
||||
Using initials (upload an image or set up Gravatar)
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Gravatar Info -->
|
||||
<?php if ($avatar['type'] !== 'gravatar' && !empty($user['email'])): ?>
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
<a href="https://gravatar.com" target="_blank" class="text-blue-600 hover:text-blue-800">
|
||||
{% if avatar.type != 'gravatar' and user.email %}
|
||||
<div class="mt-1 text-xs text-gray-400 dark:text-slate-500">
|
||||
<a href="https://gravatar.com" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
Set up Gravatar for automatic avatar
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/profile/update" class="p-6">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<div class="space-y-5">
|
||||
<!-- Full Name -->
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
Full Name
|
||||
</label>
|
||||
<input type="text" id="full_name" name="full_name"
|
||||
value="<?= htmlspecialchars($user['full_name'] ?? '') ?>"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
value="{{ user.full_name|default('') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input type="email" id="email" name="email"
|
||||
value="<?= htmlspecialchars($user['email'] ?? '') ?>"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
value="{{ user.email|default('') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
|
||||
<?php if (!empty($user['email_verified'])): ?>
|
||||
<p class="text-xs text-green-600 mt-1.5">
|
||||
{% if user.email_verified %}
|
||||
<p class="text-xs text-green-600 dark:text-green-400 mt-1.5">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
Email verified
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<div class="mt-3 bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
{% else %}
|
||||
<div class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 rounded-lg p-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 mt-0.5 mr-2"></i>
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 dark:text-amber-400 mt-0.5 mr-2"></i>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-amber-900">Email Not Verified</p>
|
||||
<p class="text-xs text-amber-700 mt-0.5">Verify your email to unlock all features</p>
|
||||
<p class="text-xs font-semibold text-amber-900 dark:text-amber-400">Email Not Verified</p>
|
||||
<p class="text-xs text-amber-700 dark:text-amber-300 mt-0.5">Verify your email to unlock all features</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/profile/resend-verification" class="ml-3 inline-flex items-center px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-xs rounded-lg transition-colors font-medium whitespace-nowrap">
|
||||
@@ -243,43 +232,43 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Username (Read-only) -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input type="text" id="username" name="username"
|
||||
value="<?= htmlspecialchars($user['username'] ?? '') ?>"
|
||||
value="{{ user.username|default('') }}"
|
||||
readonly
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed">
|
||||
<p class="text-xs text-gray-500 mt-1">Username cannot be changed</p>
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-50 dark:bg-slate-700 text-gray-500 dark:text-slate-400 cursor-not-allowed">
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Username cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<!-- Account Details Grid -->
|
||||
<div class="pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Account Information</h4>
|
||||
<div class="pt-4 border-t border-gray-200 dark:border-slate-700">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-slate-300 mb-3">Account Information</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Member Since</label>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
<?= date('F j, Y', strtotime($user['created_at'] ?? 'now')) ?>
|
||||
<div class="bg-gray-50 dark:bg-slate-700 rounded-lg p-3 border border-gray-200 dark:border-slate-600">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">Member Since</label>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ (user.created_at|default('now'))|date('F j, Y') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1">Last Login</label>
|
||||
<p class="text-sm font-semibold text-gray-900">
|
||||
<?= $user['last_login'] ? date('M j, Y g:i A', strtotime($user['last_login'])) : 'Never' ?>
|
||||
<div class="bg-gray-50 dark:bg-slate-700 rounded-lg p-3 border border-gray-200 dark:border-slate-600">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">Last Login</label>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ user.last_login ? user.last_login|date('M j, Y g:i A') : 'Never' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200 space-x-2">
|
||||
<button type="button" onclick="location.reload()" class="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200 dark:border-slate-700 space-x-2">
|
||||
<button type="button" onclick="location.reload()" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
@@ -293,99 +282,99 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
|
||||
<!-- Two-Factor Authentication Section -->
|
||||
<div id="section-twofactor" class="content-section hidden">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Two-Factor Authentication</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Add an extra layer of security to your account</p>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Two-Factor Authentication</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Add an extra layer of security to your account</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<?php if ($twoFactorPolicy === 'disabled'): ?>
|
||||
{% if twoFactorPolicy == 'disabled' %}
|
||||
<!-- 2FA Disabled by Admin -->
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-ban text-gray-400 text-xl mr-3"></i>
|
||||
<i class="fas fa-ban text-gray-400 dark:text-slate-500 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Two-Factor Authentication Disabled</p>
|
||||
<p class="text-sm text-gray-600 mt-1">2FA has been disabled by the administrator.</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Two-Factor Authentication Disabled</p>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">2FA has been disabled by the administrator.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (!$user['email_verified']): ?>
|
||||
{% elseif not user.email_verified %}
|
||||
<!-- Email Not Verified -->
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<div class="bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 text-xl mr-3"></i>
|
||||
<i class="fas fa-exclamation-triangle text-amber-600 dark:text-amber-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-900">Email Verification Required</p>
|
||||
<p class="text-sm text-amber-700 mt-1">You must verify your email address before enabling 2FA.</p>
|
||||
<p class="text-sm font-medium text-amber-900 dark:text-amber-400">Email Verification Required</p>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">You must verify your email address before enabling 2FA.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($twoFactorStatus['enabled']): ?>
|
||||
{% elseif twoFactorStatus.enabled %}
|
||||
<!-- 2FA Enabled -->
|
||||
<div class="space-y-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-shield-alt text-green-600 text-xl mr-3"></i>
|
||||
<i class="fas fa-shield-alt text-green-600 dark:text-green-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-900">Two-Factor Authentication Enabled</p>
|
||||
<p class="text-sm text-green-700 mt-1">
|
||||
<p class="text-sm font-medium text-green-900 dark:text-green-400">Two-Factor Authentication Enabled</p>
|
||||
<p class="text-sm text-green-700 dark:text-green-300 mt-1">
|
||||
Your account is protected with 2FA since
|
||||
<?= date('M j, Y', strtotime($twoFactorStatus['setup_at'])) ?>.
|
||||
{{ twoFactorStatus.setup_at|date('M j, Y') }}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Backup Codes</p>
|
||||
<p class="text-sm text-gray-600"><?= $twoFactorStatus['backup_codes_count'] ?> remaining</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Backup Codes</p>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">{{ twoFactorStatus.backup_codes_count }} remaining</p>
|
||||
</div>
|
||||
<i class="fas fa-key text-gray-400"></i>
|
||||
<i class="fas fa-key text-gray-400 dark:text-slate-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-700 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Authenticator App</p>
|
||||
<p class="text-sm text-gray-600">Active</p>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Authenticator App</p>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">Active</p>
|
||||
</div>
|
||||
<i class="fas fa-mobile-alt text-gray-400"></i>
|
||||
<i class="fas fa-mobile-alt text-gray-400 dark:text-slate-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<?php if ($twoFactorStatus['backup_codes_count'] < 3): ?>
|
||||
{% if twoFactorStatus.backup_codes_count < 3 %}
|
||||
<form method="POST" action="/2fa/regenerate-backup-codes" onsubmit="return confirm('Generate new backup codes? Your current codes will stop working.')">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-refresh mr-2"></i>
|
||||
Generate New Backup Codes
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
<?php if ($twoFactorPolicy !== 'forced'): ?>
|
||||
{% if twoFactorPolicy != 'forced' %}
|
||||
<button type="button" onclick="showDisable2FAModal()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-ban mr-2"></i>
|
||||
Disable 2FA
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($twoFactorStatus['required']): ?>
|
||||
{% elseif twoFactorStatus.required %}
|
||||
<!-- 2FA Required -->
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-red-600 text-xl mr-3"></i>
|
||||
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-red-900">Two-Factor Authentication Required</p>
|
||||
<p class="text-sm text-red-700 mt-1">You must enable 2FA to continue using your account.</p>
|
||||
<p class="text-sm font-medium text-red-900 dark:text-red-400">Two-Factor Authentication Required</p>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mt-1">You must enable 2FA to continue using your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,15 +385,15 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
Enable Two-Factor Authentication
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
{% else %}
|
||||
<!-- 2FA Optional -->
|
||||
<div class="space-y-4">
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-blue-600 text-xl mr-3"></i>
|
||||
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 text-xl mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-900">Enhanced Security Available</p>
|
||||
<p class="text-sm text-blue-700 mt-1">
|
||||
<p class="text-sm font-medium text-blue-900 dark:text-blue-400">Enhanced Security Available</p>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
Enable two-factor authentication to add an extra layer of security to your account.
|
||||
</p>
|
||||
</div>
|
||||
@@ -418,9 +407,9 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">How 2FA Works</h4>
|
||||
<ul class="text-sm text-gray-700 space-y-1">
|
||||
<div class="bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-lg p-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">How 2FA Works</h4>
|
||||
<ul class="text-sm text-gray-700 dark:text-slate-300 space-y-1">
|
||||
<li>• Generate time-based codes using an authenticator app</li>
|
||||
<li>• Use backup codes if you lose access to your device</li>
|
||||
<li>• Receive email codes as an alternative method</li>
|
||||
@@ -428,63 +417,63 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Section -->
|
||||
<div id="section-security" class="content-section hidden">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Security Settings</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Manage your password and security preferences</p>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Security Settings</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Manage your password and security preferences</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/profile/change-password" class="p-6">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<div class="space-y-4">
|
||||
<!-- Current Password -->
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
Current Password
|
||||
</label>
|
||||
<input type="password" id="current_password" name="current_password" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
placeholder="Enter your current password">
|
||||
</div>
|
||||
|
||||
<!-- New Password -->
|
||||
<div>
|
||||
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label for="new_password" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
New Password
|
||||
</label>
|
||||
<input type="password" id="new_password" name="new_password" required minlength="8"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
placeholder="Enter a strong password">
|
||||
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Minimum 8 characters</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm New Password -->
|
||||
<div>
|
||||
<label for="new_password_confirm" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label for="new_password_confirm" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input type="password" id="new_password_confirm" name="new_password_confirm" required minlength="8"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
placeholder="Re-enter your new password">
|
||||
</div>
|
||||
|
||||
<!-- Password Tips -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">
|
||||
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mr-1"></i>
|
||||
Use at least 8 characters with a mix of letters, numbers, and symbols for better security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200">
|
||||
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200 dark:border-slate-700">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
Update Password
|
||||
@@ -496,156 +485,150 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
|
||||
<!-- Active Sessions Section -->
|
||||
<div id="section-sessions" class="content-section hidden">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Active Sessions</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Manage devices and sessions where you're logged in (<?= count($sessions ?? []) ?> active)</p>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Active Sessions</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Manage devices and sessions where you're logged in ({{ sessions|default([])|length }} active)</p>
|
||||
</div>
|
||||
<?php if (count($sessions ?? []) > 1): ?>
|
||||
{% if sessions|default([])|length > 1 %}
|
||||
<form method="POST" action="/profile/logout-other-sessions" onsubmit="return confirm('Logout all other sessions?')" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="inline-flex items-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-sign-out-alt mr-1.5"></i>
|
||||
Logout Others
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<?php if (!empty($sessions)): ?>
|
||||
{% if sessions is not empty %}
|
||||
<div class="space-y-3">
|
||||
<?php foreach ($sessions as $session): ?>
|
||||
<?php
|
||||
// Display data prepared by SessionHelper in controller
|
||||
$deviceIcon = $session['deviceIcon'];
|
||||
$browserInfo = $session['browserInfo'];
|
||||
$timeAgo = $session['timeAgo'];
|
||||
$sessionAge = $session['sessionAge'];
|
||||
$isCurrent = $session['is_current'] ?? false;
|
||||
$bgClass = $isCurrent ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200';
|
||||
?>
|
||||
<div class="flex items-start justify-between p-4 <?= $bgClass ?> border rounded-lg">
|
||||
{% for session in sessions %}
|
||||
{% set isCurrent = session.is_current|default(false) %}
|
||||
{% set deviceColor = isCurrent ? 'green' : 'gray' %}
|
||||
{% set bgClass = isCurrent ? 'bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/20' : 'bg-gray-50 dark:bg-slate-700 border-gray-200 dark:border-slate-600' %}
|
||||
<div class="flex items-start justify-between p-4 {{ bgClass }} border rounded-lg">
|
||||
<div class="flex items-start space-x-3 flex-1">
|
||||
<!-- Device Icon -->
|
||||
<div class="w-10 h-10 bg-<?= $isCurrent ? 'green' : 'gray' ?>-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas <?= $deviceIcon ?> text-<?= $isCurrent ? 'green' : 'gray' ?>-600"></i>
|
||||
<div class="w-10 h-10 bg-{{ deviceColor }}-100 dark:bg-{{ deviceColor }}-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas {{ session.deviceIcon }} text-{{ deviceColor }}-600 dark:text-{{ deviceColor }}-400"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<?php if (!empty($session['country_code']) && $session['country_code'] !== 'xx'): ?>
|
||||
<span class="fi fi-<?= strtolower($session['country_code']) ?> text-base"></span>
|
||||
<?php endif; ?>
|
||||
<h4 class="text-sm font-semibold text-gray-900">
|
||||
<?= htmlspecialchars($session['city'] ?? 'Unknown') ?>, <?= htmlspecialchars($session['country'] ?? 'Unknown') ?>
|
||||
{% if session.country_code and session.country_code != 'xx' %}
|
||||
<span class="fi fi-{{ session.country_code|lower }} text-base"></span>
|
||||
{% endif %}
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ session.city|default('Unknown') }}, {{ session.country|default('Unknown') }}
|
||||
</h4>
|
||||
<?php if ($isCurrent): ?>
|
||||
{% if isCurrent %}
|
||||
<span class="px-2 py-0.5 bg-green-500 text-white text-xs font-semibold rounded">
|
||||
Current
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($session['has_remember_token'])): ?>
|
||||
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs font-semibold rounded" title="Remember me enabled">
|
||||
{% endif %}
|
||||
{% if session.has_remember_token %}
|
||||
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-xs font-semibold rounded" title="Remember me enabled">
|
||||
<i class="fas fa-cookie-bite"></i>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Browser & OS -->
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">
|
||||
<i class="fas fa-globe mr-1"></i>
|
||||
<?= htmlspecialchars($browserInfo) ?>
|
||||
<?php if (!empty($session['user_agent'])): ?>
|
||||
- <?= htmlspecialchars(substr($session['user_agent'], 0, 60)) ?><?= strlen($session['user_agent']) > 60 ? '...' : '' ?>
|
||||
<?php endif; ?>
|
||||
{{ session.browserInfo }}
|
||||
{% if session.user_agent %}
|
||||
- {{ session.user_agent|slice(0, 60) }}{{ session.user_agent|length > 60 ? '...' : '' }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<!-- IP & ISP -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-500 mt-1">
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-500 dark:text-slate-400 mt-1">
|
||||
<span>
|
||||
<i class="fas fa-map-marker-alt mr-1"></i>
|
||||
<?= htmlspecialchars($session['ip_address']) ?>
|
||||
{{ session.ip_address }}
|
||||
</span>
|
||||
<?php if (!empty($session['isp'])): ?>
|
||||
{% if session.isp %}
|
||||
<span>
|
||||
<i class="fas fa-network-wired mr-1"></i>
|
||||
<?= htmlspecialchars($session['isp']) ?>
|
||||
{{ session.isp }}
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Session Age & Last Activity -->
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400 mt-1">
|
||||
<span title="Session started: <?= date('M j, Y H:i', strtotime($session['created_at'])) ?>">
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||
<span title="Session started: {{ session.created_at|date('M j, Y H:i') }}">
|
||||
<i class="fas fa-hourglass-start mr-1"></i>
|
||||
<?= $sessionAge ?>
|
||||
{{ session.sessionAge }}
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
Active <?= $timeAgo ?>
|
||||
Active {{ session.timeAgo }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Button (only for non-current sessions) -->
|
||||
<?php if (!$isCurrent): ?>
|
||||
<form method="POST" action="/profile/logout-session/<?= htmlspecialchars($session['id']) ?>" onsubmit="return confirm('Terminate this session?\n\nThat device will be logged out immediately.')" class="ml-3">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 text-red-600 rounded-lg hover:bg-red-600 hover:text-white transition-colors" title="Terminate session">
|
||||
{% if not isCurrent %}
|
||||
<form method="POST" action="/profile/logout-session/{{ session.id }}" onsubmit="return confirm('Terminate this session?\n\nThat device will be logged out immediately.')" class="ml-3">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-600 hover:text-white dark:hover:bg-red-600 transition-colors" title="Terminate session">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mt-4">
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-lg p-3 mt-4">
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">
|
||||
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mr-1"></i>
|
||||
If you see any suspicious sessions or don't recognize a device, logout other sessions immediately and change your password.
|
||||
</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<i class="fas fa-laptop text-gray-300 text-4xl mb-3"></i>
|
||||
<p class="text-sm text-gray-600">No active sessions found</p>
|
||||
<i class="fas fa-laptop text-gray-300 dark:text-slate-600 text-4xl mb-3"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">No active sessions found</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone Section -->
|
||||
<?php if ($user['role'] !== 'admin'): ?>
|
||||
{% if user.role != 'admin' %}
|
||||
<div id="section-danger" class="content-section hidden">
|
||||
<div class="bg-white rounded-lg border border-red-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-red-200 bg-red-50">
|
||||
<h3 class="text-lg font-semibold text-red-900">Danger Zone</h3>
|
||||
<p class="text-sm text-red-700 mt-1">Irreversible and destructive actions</p>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-500/20 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-red-200 dark:border-red-500/20 bg-red-50 dark:bg-red-500/10">
|
||||
<h3 class="text-lg font-semibold text-red-900 dark:text-red-400">Danger Zone</h3>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mt-1">Irreversible and destructive actions</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-bold text-red-900">Delete Account Permanently</h4>
|
||||
<p class="text-sm text-red-700 mt-2">
|
||||
<h4 class="text-sm font-bold text-red-900 dark:text-red-400">Delete Account Permanently</h4>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mt-2">
|
||||
Once you delete your account, there is no going back. This will permanently delete all your profile information and account settings.
|
||||
</p>
|
||||
<p class="text-xs text-red-800 font-semibold mt-3 bg-red-100 inline-block px-2 py-1 rounded">
|
||||
<p class="text-xs text-red-800 dark:text-red-400 font-semibold mt-3 bg-red-100 dark:bg-red-500/20 inline-block px-2 py-1 rounded">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
</div>
|
||||
<form id="deleteAccountForm" method="POST" action="/profile/delete" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<button type="button" onclick="confirmDelete()" class="ml-4 inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium whitespace-nowrap">
|
||||
<i class="fas fa-trash-alt mr-2"></i>
|
||||
Delete Account
|
||||
@@ -656,34 +639,39 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Navigation Styles */
|
||||
.nav-item {
|
||||
color: #6b7280;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dark .nav-item {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.dark .nav-item:hover {
|
||||
background-color: #334155;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
.nav-item.active {
|
||||
background-color: #EFF6FF;
|
||||
color: #4A90E2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Content Section Animations */
|
||||
.dark .nav-item.active {
|
||||
background-color: rgba(74, 144, 226, 0.15);
|
||||
color: #60a5fa;
|
||||
}
|
||||
.content-section {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -698,38 +686,28 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
|
||||
|
||||
<script>
|
||||
function showSection(section) {
|
||||
// Hide all sections
|
||||
document.querySelectorAll('.content-section').forEach(el => {
|
||||
el.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Remove active class from all nav items
|
||||
document.querySelectorAll('.nav-item').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected section
|
||||
document.getElementById('section-' + section).classList.remove('hidden');
|
||||
|
||||
// Add active class to selected nav item
|
||||
document.getElementById('nav-' + section).classList.add('active');
|
||||
|
||||
// Update URL hash
|
||||
window.location.hash = section;
|
||||
|
||||
// Scroll to top smoothly
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// On page load, check URL hash and show that section
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const hash = window.location.hash.substring(1); // Remove #
|
||||
const validSections = ['profile', 'security', 'twofactor', 'sessions'<?php if ($user['role'] !== 'admin'): ?>, 'danger'<?php endif; ?>];
|
||||
const hash = window.location.hash.substring(1);
|
||||
const validSections = ['profile', 'security', 'twofactor', 'sessions'{% if user.role != 'admin' %}, 'danger'{% endif %}];
|
||||
|
||||
if (hash && validSections.includes(hash)) {
|
||||
showSection(hash);
|
||||
} else {
|
||||
// Default to profile section
|
||||
showSection('profile');
|
||||
}
|
||||
});
|
||||
@@ -754,22 +732,22 @@ function hideDisable2FAModal() {
|
||||
</script>
|
||||
|
||||
<!-- Disable 2FA Modal -->
|
||||
<div id="disable2FAModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div id="disable2FAModal" class="hidden fixed inset-0 bg-gray-600/50 dark:bg-black/60 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-gray-200 dark:border-slate-700 w-96 shadow-lg rounded-md bg-white dark:bg-slate-800">
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full mb-4">
|
||||
<i class="fas fa-ban text-red-600 text-xl"></i>
|
||||
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 dark:bg-red-500/20 rounded-full mb-4">
|
||||
<i class="fas fa-ban text-red-600 dark:text-red-400 text-xl"></i>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-medium text-gray-900 text-center mb-2">Disable Two-Factor Authentication</h3>
|
||||
<p class="text-sm text-gray-500 text-center mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white text-center mb-2">Disable Two-Factor Authentication</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400 text-center mb-6">
|
||||
This will make your account less secure. Enter your 2FA code to confirm.
|
||||
</p>
|
||||
|
||||
<form id="disable2FAForm" method="POST" action="/2fa/disable" class="space-y-4">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
<div>
|
||||
<label for="disable2FACode" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="disable2FACode" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Verification Code
|
||||
</label>
|
||||
<input type="text"
|
||||
@@ -777,9 +755,9 @@ function hideDisable2FAModal() {
|
||||
name="verification_code"
|
||||
maxlength="8"
|
||||
placeholder="Enter 2FA code"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors text-sm"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
required>
|
||||
<p class="text-xs text-gray-500 mt-1">Enter your authenticator code, email code, or backup code</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Enter your authenticator code, email code, or backup code</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3 pt-4">
|
||||
@@ -789,7 +767,7 @@ function hideDisable2FAModal() {
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="hideDisable2FAModal()"
|
||||
class="flex-1 bg-gray-300 hover:bg-gray-400 text-gray-700 py-2.5 rounded-lg font-medium transition-colors text-sm">
|
||||
class="flex-1 bg-gray-300 dark:bg-slate-600 hover:bg-gray-400 dark:hover:bg-slate-500 text-gray-700 dark:text-slate-300 py-2.5 rounded-lg font-medium transition-colors text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -797,8 +775,4 @@ function hideDisable2FAModal() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
@@ -1,300 +0,0 @@
|
||||
<?php
|
||||
$title = 'Search Results';
|
||||
$pageTitle = 'Search Results';
|
||||
$pageDescription = 'Results for "' . htmlspecialchars($query) . '"';
|
||||
$pageIcon = 'fas fa-search';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<!-- Search Query Display -->
|
||||
<div class="mb-4 bg-white rounded-lg border border-gray-200 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Searching for:</p>
|
||||
<h3 class="text-lg font-semibold text-gray-900"><?= htmlspecialchars($query) ?></h3>
|
||||
</div>
|
||||
<form action="/search" method="GET" class="flex gap-2">
|
||||
<input type="text"
|
||||
name="q"
|
||||
value="<?= htmlspecialchars($query) ?>"
|
||||
placeholder="Search again..."
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"
|
||||
autofocus>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
<i class="fas fa-search mr-2"></i>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<?php if (!empty($existingDomains) && $pagination['total'] > 0): ?>
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?></span> result(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/search" class="flex items-center gap-2">
|
||||
<input type="hidden" name="q" value="<?= htmlspecialchars($query) ?>">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="10" <?= $pagination['per_page'] == 10 ? 'selected' : '' ?>>10</option>
|
||||
<option value="25" <?= $pagination['per_page'] == 25 ? 'selected' : '' ?>>25</option>
|
||||
<option value="50" <?= $pagination['per_page'] == 50 ? 'selected' : '' ?>>50</option>
|
||||
<option value="100" <?= $pagination['per_page'] == 100 ? 'selected' : '' ?>>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($existingDomains)): ?>
|
||||
<!-- Existing Domains Found -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-green-50">
|
||||
<h2 class="text-lg font-semibold text-green-900 flex items-center">
|
||||
<i class="fas fa-check-circle text-green-600 mr-2"></i>
|
||||
Found <?= $pagination['total'] ?> Matching Domain(s) in Your Portfolio
|
||||
</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Registrar</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Expiration</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($existingDomains as $domain): ?>
|
||||
<?php
|
||||
// Display data prepared by DomainHelper in controller
|
||||
$daysLeft = $domain['daysLeft'];
|
||||
$expiryClass = $domain['expiryClass'];
|
||||
?>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4">
|
||||
<a href="/domains/<?= $domain['id'] ?>" class="text-sm font-semibold text-primary hover:text-primary-dark">
|
||||
<?= htmlspecialchars($domain['domain_name']) ?>
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
<?= htmlspecialchars($domain['registrar'] ?? 'Unknown') ?>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<?php if (!empty($domain['expiration_date'])): ?>
|
||||
<div class="text-sm">
|
||||
<div class="font-medium text-gray-900"><?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?></div>
|
||||
<div class="text-xs <?= $expiryClass ?>">
|
||||
<?= $daysLeft ?> days
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-sm text-gray-400">Not set</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<?php
|
||||
$statusClass = $domain['status'] === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800';
|
||||
?>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold <?= $statusClass ?>">
|
||||
<?= ucfirst($domain['status']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<a href="/domains/<?= $domain['id'] ?>" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
||||
View Details →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<?php if ($pagination['total_pages'] > 1): ?>
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Page Info -->
|
||||
<div class="text-sm text-gray-600">
|
||||
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?></span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
<?php
|
||||
$currentPage = $pagination['current_page'];
|
||||
$totalPages = $pagination['total_pages'];
|
||||
|
||||
// Helper function to build pagination URL
|
||||
function paginationUrl($page, $query, $perPage) {
|
||||
return '/search?q=' . urlencode($query) . '&page=' . $page . '&per_page=' . $perPage;
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- First Page -->
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl(1, $query, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Previous Page -->
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl($currentPage - 1, $query, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Page Numbers -->
|
||||
<?php
|
||||
$range = 2; // Show 2 pages on each side of current page
|
||||
$start = max(1, $currentPage - $range);
|
||||
$end = min($totalPages, $currentPage + $range);
|
||||
|
||||
// Show first page + ellipsis if needed
|
||||
if ($start > 1) {
|
||||
echo '<a href="' . paginationUrl(1, $query, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
|
||||
if ($start > 2) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Page numbers
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
if ($i == $currentPage) {
|
||||
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
|
||||
} else {
|
||||
echo '<a href="' . paginationUrl($i, $query, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
// Show last page + ellipsis if needed
|
||||
if ($end < $totalPages) {
|
||||
if ($end < $totalPages - 1) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
echo '<a href="' . paginationUrl($totalPages, $query, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Next Page -->
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($currentPage + 1, $query, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Last Page -->
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($totalPages, $query, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($isDomainLike && $pagination['total'] == 0): ?>
|
||||
<!-- WHOIS Lookup Results -->
|
||||
<?php if ($whoisData): ?>
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-4">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-blue-50">
|
||||
<h2 class="text-lg font-semibold text-blue-900 flex items-center">
|
||||
<i class="fas fa-search text-blue-600 mr-2"></i>
|
||||
WHOIS Lookup Results
|
||||
</h2>
|
||||
<p class="text-sm text-blue-700 mt-1">Domain not found in your portfolio - showing WHOIS information</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 mb-1">Domain</label>
|
||||
<p class="text-lg font-semibold text-gray-900"><?= htmlspecialchars($whoisData['domain']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 mb-1">Registrar</label>
|
||||
<p class="text-lg text-gray-900"><?= htmlspecialchars($whoisData['registrar']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 mb-1">Expiration Date</label>
|
||||
<p class="text-lg text-gray-900">
|
||||
<?= $whoisData['expiration_date'] ? date('M d, Y', strtotime($whoisData['expiration_date'])) : 'N/A' ?>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 mb-1">Creation Date</label>
|
||||
<p class="text-lg text-gray-900">
|
||||
<?= $whoisData['creation_date'] ? date('M d, Y', strtotime($whoisData['creation_date'])) : 'N/A' ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php if (!empty($whoisData['nameservers'])): ?>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-semibold text-gray-600 mb-2">Nameservers</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<?php foreach ($whoisData['nameservers'] as $ns): ?>
|
||||
<span class="px-3 py-1 bg-gray-100 text-gray-700 rounded text-sm font-mono"><?= htmlspecialchars($ns) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Add Domain Button -->
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<form method="POST" action="/domains/store" class="flex items-center justify-between">
|
||||
<input type="hidden" name="domain_name" value="<?= htmlspecialchars($whoisData['domain']) ?>">
|
||||
<p class="text-sm text-gray-600">Want to monitor this domain?</p>
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Add to Portfolio
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($whoisError): ?>
|
||||
<!-- WHOIS Error -->
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-circle text-red-500 text-xl mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-red-900">WHOIS Lookup Failed</h3>
|
||||
<p class="text-sm text-red-700 mt-1"><?= htmlspecialchars($whoisError) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($pagination['total'] == 0 && !$isDomainLike): ?>
|
||||
<!-- No Results -->
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-yellow-500 text-xl mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-900">No Results Found</h3>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
No domains match your search. Try a different search term or enter a domain name to perform a WHOIS lookup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
|
||||
279
app/Views/search/results.twig
Normal file
279
app/Views/search/results.twig
Normal file
@@ -0,0 +1,279 @@
|
||||
{% extends "layout/base.twig" %}
|
||||
|
||||
{% set title = 'Search Results' %}
|
||||
{% set pageTitle = 'Search Results' %}
|
||||
{% set pageDescription = 'Results for "' ~ query ~ '"' %}
|
||||
{% set pageIcon = 'fas fa-search' %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Search Query Display -->
|
||||
<div class="mb-4 bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">Searching for:</p>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ query }}</h3>
|
||||
</div>
|
||||
<form action="/search" method="GET" class="flex gap-2">
|
||||
<input type="text"
|
||||
name="q"
|
||||
value="{{ query }}"
|
||||
placeholder="Search again..."
|
||||
class="px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
|
||||
autofocus>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
<i class="fas fa-search mr-2"></i>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
{% if existingDomains is not empty and pagination.total > 0 %}
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_from }}</span> to
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_to }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total }}</span> result(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/search" class="flex items-center gap-2">
|
||||
<input type="hidden" name="q" value="{{ query }}">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600 dark:text-slate-400">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="10" {{ pagination.per_page == 10 ? 'selected' : '' }}>10</option>
|
||||
<option value="25" {{ pagination.per_page == 25 ? 'selected' : '' }}>25</option>
|
||||
<option value="50" {{ pagination.per_page == 50 ? 'selected' : '' }}>50</option>
|
||||
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if existingDomains is not empty %}
|
||||
<!-- Existing Domains Found -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden mb-4">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 bg-green-50 dark:bg-green-500/10">
|
||||
<h2 class="text-lg font-semibold text-green-900 dark:text-green-400 flex items-center">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-500 mr-2"></i>
|
||||
Found {{ pagination.total }} Matching Domain(s) in Your Portfolio
|
||||
</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Registrar</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Expiration</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for domain in existingDomains %}
|
||||
{% set daysLeft = domain.daysLeft %}
|
||||
{% set expiryClass = domain.expiryClass %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<td class="px-6 py-4">
|
||||
<a href="/domains/{{ domain.id }}" class="text-sm font-semibold text-primary hover:text-primary-dark">
|
||||
{{ domain.domain_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900 dark:text-white">
|
||||
{{ domain.registrar ?? 'Unknown' }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if domain.expiration_date is not empty %}
|
||||
<div class="text-sm">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{{ domain.expiration_date ? domain.expiration_date|date('M d, Y') : 'Unknown' }}</div>
|
||||
<div class="text-xs {{ expiryClass }}">
|
||||
{{ daysLeft }} days
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-400 dark:text-slate-500">Not set</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if domain.status == 'active' %}
|
||||
{% set statusClass = 'bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400' %}
|
||||
{% else %}
|
||||
{% set statusClass = 'bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-300' %}
|
||||
{% endif %}
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold {{ statusClass }}">
|
||||
{{ domain.status|capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium">
|
||||
View Details →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{% if pagination.total_pages > 1 %}
|
||||
{% set currentPage = pagination.current_page %}
|
||||
{% set totalPages = pagination.total_pages %}
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Page Info -->
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Page <span class="font-semibold text-gray-900 dark:text-white">{{ currentPage }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ totalPages }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- First Page -->
|
||||
{% if currentPage > 1 %}
|
||||
<a href="{{ pagination_url(1, {q: query}, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Previous Page -->
|
||||
{% if currentPage > 1 %}
|
||||
<a href="{{ pagination_url(currentPage - 1, {q: query}, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page Numbers -->
|
||||
{% set range = 2 %}
|
||||
{% set startPage = max(1, currentPage - range) %}
|
||||
{% set endPage = min(totalPages, currentPage + range) %}
|
||||
|
||||
{% if startPage > 1 %}
|
||||
<a href="{{ pagination_url(1, {q: query}, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">1</a>
|
||||
{% if startPage > 2 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for i in startPage..endPage %}
|
||||
{% if i == currentPage %}
|
||||
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">{{ i }}</span>
|
||||
{% else %}
|
||||
<a href="{{ pagination_url(i, {q: query}, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ i }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if endPage < totalPages %}
|
||||
{% if endPage < totalPages - 1 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
<a href="{{ pagination_url(totalPages, {q: query}, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ totalPages }}</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Next Page -->
|
||||
{% if currentPage < totalPages %}
|
||||
<a href="{{ pagination_url(currentPage + 1, {q: query}, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Last Page -->
|
||||
{% if currentPage < totalPages %}
|
||||
<a href="{{ pagination_url(totalPages, {q: query}, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if isDomainLike and pagination.total == 0 %}
|
||||
<!-- WHOIS Lookup Results -->
|
||||
{% if whoisData %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden mb-4">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 bg-blue-50 dark:bg-blue-500/10">
|
||||
<h2 class="text-lg font-semibold text-blue-900 dark:text-blue-400 flex items-center">
|
||||
<i class="fas fa-search text-blue-600 dark:text-blue-500 mr-2"></i>
|
||||
WHOIS Lookup Results
|
||||
</h2>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">Domain not found in your portfolio - showing WHOIS information</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-slate-400 mb-1">Domain</label>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ whoisData.domain }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-slate-400 mb-1">Registrar</label>
|
||||
<p class="text-lg text-gray-900 dark:text-white">{{ whoisData.registrar }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-slate-400 mb-1">Expiration Date</label>
|
||||
<p class="text-lg text-gray-900 dark:text-white">
|
||||
{{ whoisData.expiration_date ? whoisData.expiration_date|date('M d, Y') : 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-slate-400 mb-1">Creation Date</label>
|
||||
<p class="text-lg text-gray-900 dark:text-white">
|
||||
{{ whoisData.creation_date ? whoisData.creation_date|date('M d, Y') : 'N/A' }}
|
||||
</p>
|
||||
</div>
|
||||
{% if whoisData.nameservers is not empty %}
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-semibold text-gray-600 dark:text-slate-400 mb-2">Nameservers</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for ns in whoisData.nameservers %}
|
||||
<span class="px-3 py-1 bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 rounded text-sm font-mono">{{ ns }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Add Domain Button -->
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-slate-700">
|
||||
<form method="POST" action="/domains/store" class="flex items-center justify-between">
|
||||
<input type="hidden" name="domain_name" value="{{ whoisData.domain }}">
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400">Want to monitor this domain?</p>
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Add to Portfolio
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elseif whoisError %}
|
||||
<!-- WHOIS Error -->
|
||||
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 rounded-lg p-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-circle text-red-500 text-xl mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-red-900 dark:text-red-400">WHOIS Lookup Failed</h3>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mt-1">{{ whoisError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if pagination.total == 0 and not isDomainLike %}
|
||||
<!-- No Results -->
|
||||
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 rounded-lg p-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-yellow-500 text-xl mr-3 mt-0.5"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-900 dark:text-yellow-400">No Results Found</h3>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
No domains match your search. Try a different search term or enter a domain name to perform a WHOIS lookup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,417 +0,0 @@
|
||||
<?php
|
||||
$title = 'Tag: ' . htmlspecialchars($tag['name']);
|
||||
$pageTitle = 'Tag: ' . htmlspecialchars($tag['name']);
|
||||
$pageDescription = 'View all domains that have this tag assigned';
|
||||
$pageIcon = 'fas fa-tag';
|
||||
ob_start();
|
||||
|
||||
// Helper function to generate sort URL
|
||||
function sortUrl($column, $currentSort, $currentOrder, $tagId) {
|
||||
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
|
||||
$params = $_GET;
|
||||
$params['sort'] = $column;
|
||||
$params['order'] = $newOrder;
|
||||
return '/tags/' . $tagId . '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
// Helper function for sort icon
|
||||
function sortIcon($column, $currentSort, $currentOrder) {
|
||||
if ($currentSort !== $column) {
|
||||
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
|
||||
}
|
||||
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
|
||||
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
|
||||
}
|
||||
|
||||
// Get current filters
|
||||
$currentFilters = $filters ?? ['search' => '', 'status' => '', 'registrar' => '', 'sort' => 'domain_name', 'order' => 'asc'];
|
||||
?>
|
||||
|
||||
<!-- Back Navigation -->
|
||||
<div class="mb-4">
|
||||
<a href="/tags" class="inline-flex items-center px-3 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Tags
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tag Info Card -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium border <?= htmlspecialchars($tag['color']) ?>">
|
||||
<i class="fas fa-tag mr-1"></i>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">Tag Description</h3>
|
||||
<p class="text-xs text-gray-600 leading-relaxed">
|
||||
<?php if (!empty($tag['description'])): ?>
|
||||
<?= htmlspecialchars($tag['description']) ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<form method="GET" action="/tags/<?= $tag['id'] ?>" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search</label>
|
||||
<div class="relative">
|
||||
<input type="text" name="search" id="domainSearch" value="<?= htmlspecialchars($currentFilters['search']) ?>" placeholder="Search domains..." class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
|
||||
<select name="status" id="statusFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active" <?= $currentFilters['status'] === 'active' ? 'selected' : '' ?>>Active</option>
|
||||
<option value="expiring_soon" <?= $currentFilters['status'] === 'expiring_soon' ? 'selected' : '' ?>>Expiring Soon</option>
|
||||
<option value="expired" <?= $currentFilters['status'] === 'expired' ? 'selected' : '' ?>>Expired</option>
|
||||
<option value="available" <?= $currentFilters['status'] === 'available' ? 'selected' : '' ?>>Available</option>
|
||||
<option value="redemption_period" <?= $currentFilters['status'] === 'redemption_period' ? 'selected' : '' ?>>Redemption Period</option>
|
||||
<option value="pending_delete" <?= $currentFilters['status'] === 'pending_delete' ? 'selected' : '' ?>>Pending Delete</option>
|
||||
<option value="error" <?= $currentFilters['status'] === 'error' ? 'selected' : '' ?>>Error</option>
|
||||
<option value="inactive" <?= $currentFilters['status'] === 'inactive' ? 'selected' : '' ?>>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Registrar</label>
|
||||
<select name="registrar" id="registrarFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">All Registrars</option>
|
||||
<?php
|
||||
$registrars = array_unique(array_column($domains, 'registrar'));
|
||||
$registrars = array_filter($registrars);
|
||||
foreach ($registrars as $registrar):
|
||||
?>
|
||||
<option value="<?= htmlspecialchars($registrar) ?>" <?= ($currentFilters['registrar'] ?? '') === $registrar ? 'selected' : '' ?>><?= htmlspecialchars($registrar) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply
|
||||
</button>
|
||||
<a href="/tags/<?= $tag['id'] ?>" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?? 1 ?></span> to
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?? count($domains) ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?? count($domains) ?></span> domain(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/tags/<?= $tag['id'] ?>" class="flex items-center gap-2">
|
||||
<!-- Preserve current filters -->
|
||||
<input type="hidden" name="search" value="<?= htmlspecialchars($currentFilters['search']) ?>">
|
||||
<input type="hidden" name="status" value="<?= htmlspecialchars($currentFilters['status']) ?>">
|
||||
<input type="hidden" name="registrar" value="<?= htmlspecialchars($currentFilters['registrar']) ?>">
|
||||
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="10" <?= ($pagination['per_page'] ?? 25) == 10 ? 'selected' : '' ?>>10</option>
|
||||
<option value="25" <?= ($pagination['per_page'] ?? 25) == 25 ? 'selected' : '' ?>>25</option>
|
||||
<option value="50" <?= ($pagination['per_page'] ?? 25) == 50 ? 'selected' : '' ?>>50</option>
|
||||
<option value="100" <?= ($pagination['per_page'] ?? 25) == 100 ? 'selected' : '' ?>>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Domains List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<?php if (!empty($domains)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('domain_name', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
|
||||
Domain <?= sortIcon('domain_name', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('registrar', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
|
||||
Registrar <?= sortIcon('registrar', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('expiration_date', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
|
||||
Expiration <?= sortIcon('expiration_date', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('status', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
|
||||
Status <?= sortIcon('status', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('last_checked', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
|
||||
Last Checked <?= sortIcon('last_checked', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($domains as $domain): ?>
|
||||
<?php
|
||||
// Display data prepared by DomainHelper in controller
|
||||
$daysLeft = $domain['daysLeft'];
|
||||
$expiryClass = $domain['expiryClass'];
|
||||
$statusClass = $domain['statusClass'];
|
||||
$statusText = $domain['statusText'];
|
||||
$statusIcon = $domain['statusIcon'];
|
||||
?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<a href="/domains/<?= $domain['id'] ?>" class="text-sm font-semibold text-gray-900 hover:text-primary"><?= htmlspecialchars($domain['domain_name']) ?></a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<?php if (!empty($domain['registrar'])): ?>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-building text-gray-400 mr-2"></i>
|
||||
<span class="text-sm text-gray-900"><?= htmlspecialchars($domain['registrar']) ?></span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-sm text-gray-400">Unknown</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<?php if (!empty($domain['expiration_date'])): ?>
|
||||
<div class="text-sm">
|
||||
<div class="font-medium text-gray-900 flex items-center">
|
||||
<?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?>
|
||||
</div>
|
||||
<div class="text-xs <?= $expiryClass ?>">
|
||||
<?= $daysLeft ?> days
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-sm text-gray-400">Not set</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border <?= $statusClass ?>">
|
||||
<i class="fas <?= $statusIcon ?> mr-1"></i>
|
||||
<?= $statusText ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<?php if (!empty($domain['last_checked'])): ?>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
<?= date('M d, H:i', strtotime($domain['last_checked'])) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-gray-400">Never</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/domains/<?= $domain['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</form>
|
||||
<a href="/domains/<?= $domain['id'] ?>/edit?from=/tags/<?= $tag['id'] ?>" class="text-yellow-600 hover:text-yellow-800" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Card View (Mobile) -->
|
||||
<div class="lg:hidden divide-y divide-gray-200">
|
||||
<?php foreach ($domains as $domain): ?>
|
||||
<?php
|
||||
// Display data prepared by DomainHelper in controller
|
||||
$daysLeft = $domain['daysLeft'];
|
||||
$expiryClass = $domain['expiryClass'];
|
||||
$statusClass = $domain['statusClass'];
|
||||
$statusText = $domain['statusText'];
|
||||
$statusIcon = $domain['statusIcon'];
|
||||
?>
|
||||
<div class="p-6 hover:bg-gray-50 transition-colors duration-150">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<a href="/domains/<?= $domain['id'] ?>" class="text-lg font-semibold text-gray-900 hover:text-primary"><?= htmlspecialchars($domain['domain_name']) ?></a>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border <?= $statusClass ?>">
|
||||
<i class="fas <?= $statusIcon ?> mr-1"></i>
|
||||
<?= $statusText ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<?php if (!empty($domain['registrar'])): ?>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-building text-gray-400 mr-2 w-4"></i>
|
||||
<span><?= htmlspecialchars($domain['registrar']) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($domain['expiration_date'])): ?>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-alt text-gray-400 mr-2 w-4"></i>
|
||||
<span>Expires: <?= $domain['expiration_date'] ? date('M d, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?> (<?= $daysLeft ?> days)</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock text-gray-400 mr-2 w-4"></i>
|
||||
<span><?= $domain['last_checked'] ? date('M d, H:i', strtotime($domain['last_checked'])) : 'Never checked' ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 mt-3">
|
||||
<a href="/domains/<?= $domain['id'] ?>" class="flex-1 px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i> View
|
||||
</a>
|
||||
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="flex-1">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="w-full px-3 py-1.5 bg-green-50 text-green-600 rounded text-center text-sm hover:bg-green-100 transition-colors">
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-globe text-gray-300 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Domains Found</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">This tag is not currently assigned to any domains</p>
|
||||
<a href="/domains" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<span>Add Domains</span>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<?php if (($pagination['total_pages'] ?? 1) > 1): ?>
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Page Info -->
|
||||
<div class="text-sm text-gray-600">
|
||||
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?? 1 ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?? 1 ?></span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
<?php
|
||||
$currentPage = $pagination['current_page'] ?? 1;
|
||||
$totalPages = $pagination['total_pages'] ?? 1;
|
||||
|
||||
// Helper function to build pagination URL
|
||||
function paginationUrl($page, $filters, $perPage, $tagId) {
|
||||
$params = $filters;
|
||||
$params['page'] = $page;
|
||||
$params['per_page'] = $perPage;
|
||||
return '/tags/' . $tagId . '?' . http_build_query($params);
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- First Page -->
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl(1, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Previous Page -->
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl($currentPage - 1, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Page Numbers -->
|
||||
<?php
|
||||
$range = 2; // Show 2 pages on each side of current page
|
||||
$start = max(1, $currentPage - $range);
|
||||
$end = min($totalPages, $currentPage + $range);
|
||||
|
||||
// Show first page + ellipsis if needed
|
||||
if ($start > 1) {
|
||||
echo '<a href="' . paginationUrl(1, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
|
||||
if ($start > 2) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Page numbers
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
if ($i == $currentPage) {
|
||||
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
|
||||
} else {
|
||||
echo '<a href="' . paginationUrl($i, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
// Show last page + ellipsis if needed
|
||||
if ($end < $totalPages) {
|
||||
if ($end < $totalPages - 1) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
echo '<a href="' . paginationUrl($totalPages, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Next Page -->
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($currentPage + 1, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Last Page -->
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($totalPages, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
351
app/Views/tags/view.twig
Normal file
351
app/Views/tags/view.twig
Normal file
@@ -0,0 +1,351 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Tag: ' ~ tag.name %}
|
||||
{% set pageTitle = 'Tag: ' ~ tag.name %}
|
||||
{% set pageDescription = 'View all domains that have this tag assigned' %}
|
||||
{% set pageIcon = 'fas fa-tag' %}
|
||||
|
||||
{% set currentFilters = filters|default({ search: '', status: '', registrar: '', sort: 'domain_name', order: 'asc' }) %}
|
||||
|
||||
{# Build unique registrar list for the filter dropdown #}
|
||||
{% set registrarList = [] %}
|
||||
{% for domain in domains %}
|
||||
{% if domain.registrar is not empty and domain.registrar not in registrarList %}
|
||||
{% set registrarList = registrarList|merge([domain.registrar]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% block content %}
|
||||
{# Back Navigation #}
|
||||
<div class="mb-4">
|
||||
<a href="/tags" class="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Tags
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Tag Info Card #}
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium border {{ tag.color }}">
|
||||
<i class="fas fa-tag mr-1"></i>
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">Tag Description</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 leading-relaxed">
|
||||
{% if tag.description is not empty %}
|
||||
{{ tag.description }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Filters & Search #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 mb-4">
|
||||
<form method="GET" action="/tags/{{ tag.id }}" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Search</label>
|
||||
<div class="relative">
|
||||
<input type="text" name="search" id="domainSearch" value="{{ currentFilters.search }}" placeholder="Search domains..." class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Status</label>
|
||||
<select name="status" id="statusFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active" {{ currentFilters.status == 'active' ? 'selected' : '' }}>Active</option>
|
||||
<option value="expiring_soon" {{ currentFilters.status == 'expiring_soon' ? 'selected' : '' }}>Expiring Soon</option>
|
||||
<option value="expired" {{ currentFilters.status == 'expired' ? 'selected' : '' }}>Expired</option>
|
||||
<option value="available" {{ currentFilters.status == 'available' ? 'selected' : '' }}>Available</option>
|
||||
<option value="redemption_period" {{ currentFilters.status == 'redemption_period' ? 'selected' : '' }}>Redemption Period</option>
|
||||
<option value="pending_delete" {{ currentFilters.status == 'pending_delete' ? 'selected' : '' }}>Pending Delete</option>
|
||||
<option value="error" {{ currentFilters.status == 'error' ? 'selected' : '' }}>Error</option>
|
||||
<option value="inactive" {{ currentFilters.status == 'inactive' ? 'selected' : '' }}>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Registrar</label>
|
||||
<select name="registrar" id="registrarFilter" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="">All Registrars</option>
|
||||
{% for registrar in registrarList %}
|
||||
<option value="{{ registrar }}" {{ (currentFilters.registrar|default('')) == registrar ? 'selected' : '' }}>{{ registrar }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply
|
||||
</button>
|
||||
<a href="/tags/{{ tag.id }}" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="sort" value="{{ currentFilters.sort }}">
|
||||
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Pagination Info & Per Page Selector #}
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_from|default(1) }}</span> to
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_to|default(domains|length) }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total|default(domains|length) }}</span> domain(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/tags/{{ tag.id }}" class="flex items-center gap-2">
|
||||
<input type="hidden" name="search" value="{{ currentFilters.search }}">
|
||||
<input type="hidden" name="status" value="{{ currentFilters.status }}">
|
||||
<input type="hidden" name="registrar" value="{{ currentFilters.registrar }}">
|
||||
<input type="hidden" name="sort" value="{{ currentFilters.sort }}">
|
||||
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600 dark:text-slate-400">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||
<option value="10" {{ (pagination.per_page|default(25)) == 10 ? 'selected' : '' }}>10</option>
|
||||
<option value="25" {{ (pagination.per_page|default(25)) == 25 ? 'selected' : '' }}>25</option>
|
||||
<option value="50" {{ (pagination.per_page|default(25)) == 50 ? 'selected' : '' }}>50</option>
|
||||
<option value="100" {{ (pagination.per_page|default(25)) == 100 ? 'selected' : '' }}>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Domains List #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
{% if domains is not empty %}
|
||||
{# Table View (Desktop) #}
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('domain_name', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Domain {{ sort_icon('domain_name', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('registrar', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Registrar {{ sort_icon('registrar', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('expiration_date', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Expiration {{ sort_icon('expiration_date', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('status', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Status {{ sort_icon('status', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('last_checked', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Last Checked {{ sort_icon('last_checked', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for domain in domains %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<a href="/domains/{{ domain.id }}" class="text-sm font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if domain.registrar is not empty %}
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-building text-gray-400 dark:text-slate-500 mr-2"></i>
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ domain.registrar }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-400 dark:text-slate-500">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if domain.expiration_date is not empty %}
|
||||
<div class="text-sm">
|
||||
<div class="font-medium text-gray-900 dark:text-white flex items-center">
|
||||
{{ domain.expiration_date|date('M d, Y') }}
|
||||
</div>
|
||||
<div class="text-xs {{ domain.expiryClass }}">
|
||||
{{ domain.daysLeft }} days
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-400 dark:text-slate-500">Not set</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border {{ domain.statusClass }}">
|
||||
<i class="fas {{ domain.statusIcon }} mr-1"></i>
|
||||
{{ domain.statusText }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-slate-400">
|
||||
{% if domain.last_checked is not empty %}
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
{{ domain.last_checked|date('M d, H:i') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-slate-500">Never</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
</form>
|
||||
<a href="/domains/{{ domain.id }}/edit?from=/tags/{{ tag.id }}" class="text-yellow-600 hover:text-yellow-800" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Card View (Mobile) #}
|
||||
<div class="lg:hidden divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for domain in domains %}
|
||||
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<a href="/domains/{{ domain.id }}" class="text-lg font-semibold text-gray-900 dark:text-white hover:text-primary">{{ domain.domain_name }}</a>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border {{ domain.statusClass }}">
|
||||
<i class="fas {{ domain.statusIcon }} mr-1"></i>
|
||||
{{ domain.statusText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
{% if domain.registrar is not empty %}
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-building text-gray-400 dark:text-slate-500 mr-2 w-4"></i>
|
||||
<span class="text-gray-700 dark:text-slate-300">{{ domain.registrar }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if domain.expiration_date is not empty %}
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar-alt text-gray-400 dark:text-slate-500 mr-2 w-4"></i>
|
||||
<span class="text-gray-700 dark:text-slate-300">Expires: {{ domain.expiration_date|date('M d, Y') }} ({{ domain.daysLeft }} days)</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock text-gray-400 dark:text-slate-500 mr-2 w-4"></i>
|
||||
<span class="text-gray-700 dark:text-slate-300">{{ domain.last_checked ? domain.last_checked|date('M d, H:i') : 'Never checked' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 mt-3">
|
||||
<a href="/domains/{{ domain.id }}" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i> View
|
||||
</a>
|
||||
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="flex-1">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="w-full px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors">
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-globe text-gray-300 dark:text-slate-600 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-slate-300 mb-1">No Domains Found</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">This tag is not currently assigned to any domains</p>
|
||||
<a href="/domains" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<span>Add Domains</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Pagination Controls #}
|
||||
{% if (pagination.total_pages|default(1)) > 1 %}
|
||||
{% set currentPage = pagination.current_page|default(1) %}
|
||||
{% set totalPages = pagination.total_pages|default(1) %}
|
||||
{% set paginationRange = 2 %}
|
||||
{% set startPage = max(1, currentPage - paginationRange) %}
|
||||
{% set endPage = min(totalPages, currentPage + paginationRange) %}
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Page <span class="font-semibold text-gray-900 dark:text-white">{{ currentPage }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ totalPages }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{% if currentPage > 1 %}
|
||||
<a href="{{ pagination_url(1, currentFilters, pagination.per_page|default(25)) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<a href="{{ pagination_url(currentPage - 1, currentFilters, pagination.per_page|default(25)) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if startPage > 1 %}
|
||||
<a href="{{ pagination_url(1, currentFilters, pagination.per_page|default(25)) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">1</a>
|
||||
{% if startPage > 2 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for i in startPage..endPage %}
|
||||
{% if i == currentPage %}
|
||||
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">{{ i }}</span>
|
||||
{% else %}
|
||||
<a href="{{ pagination_url(i, currentFilters, pagination.per_page|default(25)) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ i }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if endPage < totalPages %}
|
||||
{% if endPage < totalPages - 1 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
<a href="{{ pagination_url(totalPages, currentFilters, pagination.per_page|default(25)) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ totalPages }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if currentPage < totalPages %}
|
||||
<a href="{{ pagination_url(currentPage + 1, currentFilters, pagination.per_page|default(25)) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<a href="{{ pagination_url(totalPages, currentFilters, pagination.per_page|default(25)) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,562 +0,0 @@
|
||||
<?php
|
||||
$title = 'TLD Import Logs';
|
||||
$pageTitle = 'TLD Import Logs';
|
||||
$pageDescription = 'History of TLD registry import operations';
|
||||
$pageIcon = 'fas fa-history';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<!-- Header with Actions -->
|
||||
<div class="mb-4 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Import Logs</h1>
|
||||
<p class="text-gray-600 mt-1">History of TLD registry import operations</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/tld-registry" class="inline-flex items-center px-4 py-2.5 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Registry
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Total Imports Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Imports</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $importStats['total_imports'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-download text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Successful Imports Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Successful</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $importStats['successful_imports'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-check-circle text-green-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed Imports Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Failed</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $importStats['failed_imports'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-red-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-times-circle text-red-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Import Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Last Import</p>
|
||||
<p class="text-sm font-semibold text-gray-900 mt-1">
|
||||
<?php if (!empty($importStats['last_import'])): ?>
|
||||
<?= date('M j, H:i', strtotime($importStats['last_import'])) ?>
|
||||
<?php else: ?>
|
||||
Never
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-indigo-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-clock text-indigo-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Logs Table -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<?php if (!empty($imports)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Import Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Results</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Publication Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Started</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($imports as $import): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150"
|
||||
data-import-id="<?= $import['id'] ?>"
|
||||
data-import-data="<?= htmlspecialchars(json_encode($import)) ?>">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<?php
|
||||
$typeIcons = [
|
||||
'tld_list' => 'fa-list',
|
||||
'rdap' => 'fa-database',
|
||||
'whois' => 'fa-server',
|
||||
'complete_workflow' => 'fa-tasks',
|
||||
'check_updates' => 'fa-sync-alt',
|
||||
'manual' => 'fa-hand-pointer'
|
||||
];
|
||||
$typeLabels = [
|
||||
'tld_list' => 'TLD List',
|
||||
'rdap' => 'RDAP Servers',
|
||||
'whois' => 'WHOIS Data',
|
||||
'complete_workflow' => 'Complete Workflow',
|
||||
'check_updates' => 'Update Check',
|
||||
'manual' => 'Manual Import'
|
||||
];
|
||||
$typeDescriptions = [
|
||||
'tld_list' => 'IANA TLD list import',
|
||||
'rdap' => 'RDAP server bootstrap data',
|
||||
'whois' => 'WHOIS server & registry URLs',
|
||||
'complete_workflow' => 'Full import (TLD List → RDAP → WHOIS)',
|
||||
'check_updates' => 'IANA update verification',
|
||||
'manual' => 'Manual data import'
|
||||
];
|
||||
|
||||
$icon = $typeIcons[$import['import_type']] ?? 'fa-file-import';
|
||||
$label = $typeLabels[$import['import_type']] ?? ucfirst($import['import_type']);
|
||||
$description = $typeDescriptions[$import['import_type']] ?? 'Import operation';
|
||||
?>
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas <?= $icon ?> text-primary"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-semibold text-gray-900"><?= $label ?></div>
|
||||
<div class="text-sm text-gray-500"><?= $description ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<?php
|
||||
$statusClass = '';
|
||||
$statusIcon = '';
|
||||
$statusText = '';
|
||||
|
||||
if ($import['status'] === 'completed') {
|
||||
$statusClass = 'bg-green-100 text-green-700 border-green-200';
|
||||
$statusIcon = 'fa-check-circle';
|
||||
$statusText = 'Completed';
|
||||
} elseif ($import['status'] === 'failed') {
|
||||
$statusClass = 'bg-red-100 text-red-700 border-red-200';
|
||||
$statusIcon = 'fa-times-circle';
|
||||
$statusText = 'Failed';
|
||||
} else {
|
||||
$statusClass = 'bg-yellow-100 text-yellow-700 border-yellow-200';
|
||||
$statusIcon = 'fa-clock';
|
||||
$statusText = 'In Progress';
|
||||
}
|
||||
?>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border <?= $statusClass ?>">
|
||||
<i class="fas <?= $statusIcon ?> mr-1"></i>
|
||||
<?= $statusText ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-900">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-globe text-gray-400 mr-1"></i>
|
||||
<?= $import['total_tlds'] ?> total
|
||||
</span>
|
||||
<span class="flex items-center text-green-600">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
<?= $import['new_tlds'] ?> new
|
||||
</span>
|
||||
<span class="flex items-center text-blue-600">
|
||||
<i class="fas fa-sync mr-1"></i>
|
||||
<?= $import['updated_tlds'] ?> updated
|
||||
</span>
|
||||
<?php if ($import['failed_tlds'] > 0): ?>
|
||||
<span class="flex items-center text-red-600">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
<?= $import['failed_tlds'] ?> failed
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<?php if ($import['iana_publication_date']): ?>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-calendar mr-2"></i>
|
||||
<?php
|
||||
$date = $import['iana_publication_date'];
|
||||
// Try to parse the date, if it fails, display as-is
|
||||
$parsedDate = strtotime($date);
|
||||
if ($parsedDate && $parsedDate > 0) {
|
||||
echo date('M j, Y', $parsedDate);
|
||||
} else {
|
||||
echo htmlspecialchars($date);
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-gray-400">N/A</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
<?= date('M j, H:i', strtotime($import['started_at'])) ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick="showImportDetails(<?= $import['id'] ?>)" class="text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Card View (Mobile) -->
|
||||
<div class="lg:hidden divide-y divide-gray-200">
|
||||
<?php foreach ($imports as $import): ?>
|
||||
<div class="p-6 hover:bg-gray-50 transition-colors duration-150"
|
||||
data-import-id="<?= $import['id'] ?>"
|
||||
data-import-data="<?= htmlspecialchars(json_encode($import)) ?>">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<?php
|
||||
$typeIcons = [
|
||||
'tld_list' => 'fa-list',
|
||||
'rdap' => 'fa-database',
|
||||
'whois' => 'fa-server',
|
||||
'complete_workflow' => 'fa-tasks',
|
||||
'check_updates' => 'fa-sync-alt',
|
||||
'manual' => 'fa-hand-pointer'
|
||||
];
|
||||
$typeLabels = [
|
||||
'tld_list' => 'TLD List',
|
||||
'rdap' => 'RDAP Servers',
|
||||
'whois' => 'WHOIS Data',
|
||||
'complete_workflow' => 'Complete Workflow',
|
||||
'check_updates' => 'Update Check',
|
||||
'manual' => 'Manual Import'
|
||||
];
|
||||
|
||||
$icon = $typeIcons[$import['import_type']] ?? 'fa-file-import';
|
||||
$label = $typeLabels[$import['import_type']] ?? ucfirst($import['import_type']);
|
||||
?>
|
||||
<div class="w-10 h-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas <?= $icon ?> text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900"><?= $label ?></h3>
|
||||
<p class="text-sm text-gray-500"><?= date('M j, Y H:i', strtotime($import['started_at'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
$statusClass = '';
|
||||
$statusIcon = '';
|
||||
$statusText = '';
|
||||
|
||||
if ($import['status'] === 'completed') {
|
||||
$statusClass = 'bg-green-100 text-green-700';
|
||||
$statusIcon = 'fa-check-circle';
|
||||
$statusText = 'Completed';
|
||||
} elseif ($import['status'] === 'failed') {
|
||||
$statusClass = 'bg-red-100 text-red-700';
|
||||
$statusIcon = 'fa-times-circle';
|
||||
$statusText = 'Failed';
|
||||
} else {
|
||||
$statusClass = 'bg-yellow-100 text-yellow-700';
|
||||
$statusIcon = 'fa-clock';
|
||||
$statusText = 'In Progress';
|
||||
}
|
||||
?>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold <?= $statusClass ?>">
|
||||
<i class="fas <?= $statusIcon ?> mr-1"></i>
|
||||
<?= $statusText ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Total TLDs:</span>
|
||||
<span class="font-semibold"><?= $import['total_tlds'] ?></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">New:</span>
|
||||
<span class="font-semibold text-green-600"><?= $import['new_tlds'] ?></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Updated:</span>
|
||||
<span class="font-semibold text-blue-600"><?= $import['updated_tlds'] ?></span>
|
||||
</div>
|
||||
<?php if ($import['failed_tlds'] > 0): ?>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Failed:</span>
|
||||
<span class="font-semibold text-red-600"><?= $import['failed_tlds'] ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 mt-3">
|
||||
<button onclick="showImportDetails(<?= $import['id'] ?>)" class="flex-1 px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i> Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<?php if ($pagination['total_pages'] > 1): ?>
|
||||
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
<?php if ($pagination['current_page'] > 1): ?>
|
||||
<a href="?page=<?= $pagination['current_page'] - 1 ?>"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Previous
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($pagination['current_page'] < $pagination['total_pages']): ?>
|
||||
<a href="?page=<?= $pagination['current_page'] + 1 ?>"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Next
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Showing <span class="font-medium"><?= $pagination['showing_from'] ?></span> to
|
||||
<span class="font-medium"><?= $pagination['showing_to'] ?></span> of
|
||||
<span class="font-medium"><?= $pagination['total'] ?></span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<?php for ($i = 1; $i <= $pagination['total_pages']; $i++): ?>
|
||||
<a href="?page=<?= $i ?>"
|
||||
class="relative inline-flex items-center px-4 py-2 border text-sm font-medium <?= $i === $pagination['current_page'] ? 'z-10 bg-primary border-primary text-white' : 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50' ?> <?= $i === 1 ? 'rounded-l-md' : '' ?> <?= $i === $pagination['total_pages'] ? 'rounded-r-md' : '' ?>">
|
||||
<?= $i ?>
|
||||
</a>
|
||||
<?php endfor; ?>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-history text-gray-300 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Import Logs</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">No TLD imports have been performed yet.</p>
|
||||
<a href="/tld-registry" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Registry
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Import Details Modal -->
|
||||
<div id="importDetailsModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Import Details</h3>
|
||||
<button onclick="closeImportDetails()" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="importDetailsContent" class="text-sm text-gray-600">
|
||||
<!-- Content will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showImportDetails(importId) {
|
||||
// Find the import data from the current page
|
||||
const importData = findImportData(importId);
|
||||
|
||||
if (!importData) {
|
||||
document.getElementById('importDetailsContent').innerHTML = `
|
||||
<div class="text-center text-gray-500">
|
||||
<p>Import details not found</p>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('importDetailsModal').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Type labels mapping
|
||||
const typeLabels = {
|
||||
'tld_list': 'TLD List',
|
||||
'rdap': 'RDAP Servers',
|
||||
'whois': 'WHOIS Data',
|
||||
'complete_workflow': 'Complete Workflow',
|
||||
'check_updates': 'Update Check',
|
||||
'manual': 'Manual Import'
|
||||
};
|
||||
|
||||
const typeDescriptions = {
|
||||
'tld_list': 'IANA TLD list import',
|
||||
'rdap': 'RDAP server bootstrap data',
|
||||
'whois': 'WHOIS server & registry URLs',
|
||||
'complete_workflow': 'Full import (TLD List → RDAP → WHOIS)',
|
||||
'check_updates': 'IANA update verification',
|
||||
'manual': 'Manual data import'
|
||||
};
|
||||
|
||||
const typeLabel = typeLabels[importData.import_type] || importData.import_type;
|
||||
const typeDescription = typeDescriptions[importData.import_type] || 'Import operation';
|
||||
|
||||
// Calculate duration if we have both start and completion times
|
||||
let duration = 'Unknown';
|
||||
if (importData.started_at && importData.completed_at) {
|
||||
const start = new Date(importData.started_at);
|
||||
const end = new Date(importData.completed_at);
|
||||
const diffMs = end - start;
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
const seconds = Math.floor((diffMs % 60000) / 1000);
|
||||
|
||||
// If duration is very short (< 5 seconds), it might be manually completed
|
||||
// Try to estimate from the log if it's a complete workflow
|
||||
if (diffMs < 5000 && importData.import_type === 'complete_workflow') {
|
||||
// Estimate: ~1 second per TLD for complete workflow
|
||||
const estimatedSeconds = Math.round((importData.total_tlds || 0) * 1.1);
|
||||
const estMinutes = Math.floor(estimatedSeconds / 60);
|
||||
const estSeconds = estimatedSeconds % 60;
|
||||
duration = `~${estMinutes} minutes ${estSeconds} seconds (estimated)`;
|
||||
} else if (minutes === 0 && seconds === 0) {
|
||||
duration = 'Less than 1 second';
|
||||
} else {
|
||||
duration = `${minutes} minutes ${seconds} seconds`;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine status color
|
||||
let statusClass = 'bg-gray-100 text-gray-800';
|
||||
let statusText = 'Unknown';
|
||||
if (importData.status === 'completed') {
|
||||
statusClass = 'bg-green-100 text-green-800';
|
||||
statusText = 'Completed';
|
||||
} else if (importData.status === 'failed') {
|
||||
statusClass = 'bg-red-100 text-red-800';
|
||||
statusText = 'Failed';
|
||||
} else if (importData.status === 'running') {
|
||||
statusClass = 'bg-yellow-100 text-yellow-800';
|
||||
statusText = 'Running';
|
||||
}
|
||||
|
||||
document.getElementById('importDetailsContent').innerHTML = `
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Import ID:</span>
|
||||
<span>${importData.id}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Type:</span>
|
||||
<span>${typeLabel}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Description:</span>
|
||||
<span class="text-gray-600">${typeDescription}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Status:</span>
|
||||
<span class="px-2 py-1 rounded text-xs ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Duration:</span>
|
||||
<span>${duration}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Started:</span>
|
||||
<span>${new Date(importData.started_at).toLocaleString()}</span>
|
||||
</div>
|
||||
${importData.completed_at ? `
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Completed:</span>
|
||||
<span>${new Date(importData.completed_at).toLocaleString()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${importData.iana_publication_date ? `
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">IANA Publication:</span>
|
||||
<span>${importData.iana_publication_date}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="mt-4">
|
||||
<h4 class="font-medium mb-2">Import Results:</h4>
|
||||
<div class="bg-gray-100 p-3 rounded text-xs font-mono space-y-1">
|
||||
<div>Total TLDs: ${importData.total_tlds || 0}</div>
|
||||
<div>New TLDs: ${importData.new_tlds || 0}</div>
|
||||
<div>Updated TLDs: ${importData.updated_tlds || 0}</div>
|
||||
<div>Failed TLDs: ${importData.failed_tlds || 0}</div>
|
||||
${importData.error_message ? `
|
||||
<div class="text-red-600 mt-2">
|
||||
<strong>Error:</strong> ${importData.error_message}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('importDetailsModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function findImportData(importId) {
|
||||
// Look for import data in the current page
|
||||
const importRows = document.querySelectorAll('tr[data-import-id]');
|
||||
for (let row of importRows) {
|
||||
if (row.getAttribute('data-import-id') == importId) {
|
||||
return JSON.parse(row.getAttribute('data-import-data'));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for data in mobile view
|
||||
const importCards = document.querySelectorAll('[data-import-id]');
|
||||
for (let card of importCards) {
|
||||
if (card.getAttribute('data-import-id') == importId) {
|
||||
return JSON.parse(card.getAttribute('data-import-data'));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function closeImportDetails() {
|
||||
document.getElementById('importDetailsModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('importDetailsModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeImportDetails();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
506
app/Views/tld-registry/import-logs.twig
Normal file
506
app/Views/tld-registry/import-logs.twig
Normal file
@@ -0,0 +1,506 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'TLD Import Logs' %}
|
||||
{% set pageTitle = 'TLD Import Logs' %}
|
||||
{% set pageDescription = 'History of TLD registry import operations' %}
|
||||
{% set pageIcon = 'fas fa-history' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# Header with Actions #}
|
||||
<div class="mb-4 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Import Logs</h1>
|
||||
<p class="text-gray-600 dark:text-slate-400 mt-1">History of TLD registry import operations</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/tld-registry" class="inline-flex items-center px-4 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Registry
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Statistics Cards #}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{# Total Imports Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Total Imports</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ importStats.total_imports|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-50 dark:bg-blue-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-download text-blue-600 dark:text-blue-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Successful Imports Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Successful</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ importStats.successful_imports|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-50 dark:bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Failed Imports Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Failed</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ importStats.failed_imports|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-red-50 dark:bg-red-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-times-circle text-red-600 dark:text-red-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Last Import Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Last Import</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">
|
||||
{% if importStats.last_import is not empty %}
|
||||
{{ importStats.last_import|date('M j, H:i') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-indigo-50 dark:bg-indigo-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-clock text-indigo-600 dark:text-indigo-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Import Logs Table #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
{% if imports is not empty %}
|
||||
{# Table View (Desktop) #}
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Import Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Results</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Publication Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Started</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% set typeIcons = {
|
||||
tld_list: 'fa-list',
|
||||
rdap: 'fa-database',
|
||||
whois: 'fa-server',
|
||||
complete_workflow: 'fa-tasks',
|
||||
check_updates: 'fa-sync-alt',
|
||||
manual: 'fa-hand-pointer'
|
||||
} %}
|
||||
{% set typeLabels = {
|
||||
tld_list: 'TLD List',
|
||||
rdap: 'RDAP Servers',
|
||||
whois: 'WHOIS Data',
|
||||
complete_workflow: 'Complete Workflow',
|
||||
check_updates: 'Update Check',
|
||||
manual: 'Manual Import'
|
||||
} %}
|
||||
{% set typeDescriptions = {
|
||||
tld_list: 'IANA TLD list import',
|
||||
rdap: 'RDAP server bootstrap data',
|
||||
whois: 'WHOIS server & registry URLs',
|
||||
complete_workflow: 'Full import (TLD List → RDAP → WHOIS)',
|
||||
check_updates: 'IANA update verification',
|
||||
manual: 'Manual data import'
|
||||
} %}
|
||||
{% for import in imports %}
|
||||
{% set icon = typeIcons[import.import_type]|default('fa-file-import') %}
|
||||
{% set label = typeLabels[import.import_type]|default(import.import_type|capitalize) %}
|
||||
{% set description = typeDescriptions[import.import_type]|default('Import operation') %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150"
|
||||
data-import-id="{{ import.id }}"
|
||||
data-import-data="{{ import|json_encode|e('html_attr') }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas {{ icon }} text-primary"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ label }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-slate-400">{{ description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if import.status == 'completed' %}
|
||||
{% set statusClass = 'bg-green-100 dark:bg-green-500/10 text-green-700 dark:text-green-400 border-green-200 dark:border-green-500/30' %}
|
||||
{% set statusIcon = 'fa-check-circle' %}
|
||||
{% set statusText = 'Completed' %}
|
||||
{% elseif import.status == 'failed' %}
|
||||
{% set statusClass = 'bg-red-100 dark:bg-red-500/10 text-red-700 dark:text-red-400 border-red-200 dark:border-red-500/30' %}
|
||||
{% set statusIcon = 'fa-times-circle' %}
|
||||
{% set statusText = 'Failed' %}
|
||||
{% else %}
|
||||
{% set statusClass = 'bg-yellow-100 dark:bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-500/30' %}
|
||||
{% set statusIcon = 'fa-clock' %}
|
||||
{% set statusText = 'In Progress' %}
|
||||
{% endif %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border {{ statusClass }}">
|
||||
<i class="fas {{ statusIcon }} mr-1"></i>
|
||||
{{ statusText }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-900 dark:text-white">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-globe text-gray-400 dark:text-slate-500 mr-1"></i>
|
||||
{{ import.total_tlds }} total
|
||||
</span>
|
||||
<span class="flex items-center text-green-600 dark:text-green-400">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
{{ import.new_tlds }} new
|
||||
</span>
|
||||
<span class="flex items-center text-blue-600 dark:text-blue-400">
|
||||
<i class="fas fa-sync mr-1"></i>
|
||||
{{ import.updated_tlds }} updated
|
||||
</span>
|
||||
{% if import.failed_tlds > 0 %}
|
||||
<span class="flex items-center text-red-600 dark:text-red-400">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
{{ import.failed_tlds }} failed
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-slate-400">
|
||||
{% if import.iana_publication_date %}
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-calendar mr-2"></i>
|
||||
{{ import.iana_publication_date|date('M j, Y') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-slate-500">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-slate-400">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
{{ import.started_at|date('M j, H:i') }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button onclick="showImportDetails({{ import.id }})" class="text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Card View (Mobile) #}
|
||||
<div class="lg:hidden divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for import in imports %}
|
||||
{% set icon = typeIcons[import.import_type]|default('fa-file-import') %}
|
||||
{% set label = typeLabels[import.import_type]|default(import.import_type|capitalize) %}
|
||||
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150"
|
||||
data-import-id="{{ import.id }}"
|
||||
data-import-data="{{ import|json_encode|e('html_attr') }}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas {{ icon }} text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ label }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">{{ import.started_at|date('M j, Y H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if import.status == 'completed' %}
|
||||
{% set mobileStatusClass = 'bg-green-100 dark:bg-green-500/10 text-green-700 dark:text-green-400' %}
|
||||
{% set mobileStatusIcon = 'fa-check-circle' %}
|
||||
{% set mobileStatusText = 'Completed' %}
|
||||
{% elseif import.status == 'failed' %}
|
||||
{% set mobileStatusClass = 'bg-red-100 dark:bg-red-500/10 text-red-700 dark:text-red-400' %}
|
||||
{% set mobileStatusIcon = 'fa-times-circle' %}
|
||||
{% set mobileStatusText = 'Failed' %}
|
||||
{% else %}
|
||||
{% set mobileStatusClass = 'bg-yellow-100 dark:bg-yellow-500/10 text-yellow-700 dark:text-yellow-400' %}
|
||||
{% set mobileStatusIcon = 'fa-clock' %}
|
||||
{% set mobileStatusText = 'In Progress' %}
|
||||
{% endif %}
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold {{ mobileStatusClass }}">
|
||||
<i class="fas {{ mobileStatusIcon }} mr-1"></i>
|
||||
{{ mobileStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-slate-400">Total TLDs:</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ import.total_tlds }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-slate-400">New:</span>
|
||||
<span class="font-semibold text-green-600 dark:text-green-400">{{ import.new_tlds }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-slate-400">Updated:</span>
|
||||
<span class="font-semibold text-blue-600 dark:text-blue-400">{{ import.updated_tlds }}</span>
|
||||
</div>
|
||||
{% if import.failed_tlds > 0 %}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-slate-400">Failed:</span>
|
||||
<span class="font-semibold text-red-600 dark:text-red-400">{{ import.failed_tlds }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 mt-3">
|
||||
<button onclick="showImportDetails({{ import.id }})" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i> Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
{% if pagination.total_pages > 1 %}
|
||||
<div class="bg-white dark:bg-slate-800 px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-slate-700 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
{% if pagination.current_page > 1 %}
|
||||
<a href="?page={{ pagination.current_page - 1 }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 text-sm font-medium rounded-md text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if pagination.current_page < pagination.total_pages %}
|
||||
<a href="?page={{ pagination.current_page + 1 }}"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 text-sm font-medium rounded-md text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
Next
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700 dark:text-slate-300">
|
||||
Showing <span class="font-medium">{{ pagination.showing_from }}</span> to
|
||||
<span class="font-medium">{{ pagination.showing_to }}</span> of
|
||||
<span class="font-medium">{{ pagination.total }}</span> results
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{% for i in 1..pagination.total_pages %}
|
||||
<a href="?page={{ i }}"
|
||||
class="relative inline-flex items-center px-4 py-2 border text-sm font-medium {{ i == pagination.current_page ? 'z-10 bg-primary border-primary text-white' : 'bg-white dark:bg-slate-800 border-gray-300 dark:border-slate-600 text-gray-500 dark:text-slate-400 hover:bg-gray-50 dark:hover:bg-slate-700' }} {{ i == 1 ? 'rounded-l-md' : '' }} {{ i == pagination.total_pages ? 'rounded-r-md' : '' }}">
|
||||
{{ i }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-history text-gray-300 dark:text-slate-600 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-slate-300 mb-1">No Import Logs</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">No TLD imports have been performed yet.</p>
|
||||
<a href="/tld-registry" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Registry
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Import Details Modal #}
|
||||
<div id="importDetailsModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-gray-200 dark:border-slate-700 w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white dark:bg-slate-800">
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Import Details</h3>
|
||||
<button onclick="closeImportDetails()" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="importDetailsContent" class="text-sm text-gray-600 dark:text-slate-400">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showImportDetails(importId) {
|
||||
const importData = findImportData(importId);
|
||||
|
||||
if (!importData) {
|
||||
document.getElementById('importDetailsContent').innerHTML = `
|
||||
<div class="text-center text-gray-500 dark:text-slate-400">
|
||||
<p>Import details not found</p>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('importDetailsModal').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const typeLabels = {
|
||||
'tld_list': 'TLD List',
|
||||
'rdap': 'RDAP Servers',
|
||||
'whois': 'WHOIS Data',
|
||||
'complete_workflow': 'Complete Workflow',
|
||||
'check_updates': 'Update Check',
|
||||
'manual': 'Manual Import'
|
||||
};
|
||||
|
||||
const typeDescriptions = {
|
||||
'tld_list': 'IANA TLD list import',
|
||||
'rdap': 'RDAP server bootstrap data',
|
||||
'whois': 'WHOIS server & registry URLs',
|
||||
'complete_workflow': 'Full import (TLD List → RDAP → WHOIS)',
|
||||
'check_updates': 'IANA update verification',
|
||||
'manual': 'Manual data import'
|
||||
};
|
||||
|
||||
const typeLabel = typeLabels[importData.import_type] || importData.import_type;
|
||||
const typeDescription = typeDescriptions[importData.import_type] || 'Import operation';
|
||||
|
||||
let duration = 'Unknown';
|
||||
if (importData.started_at && importData.completed_at) {
|
||||
const start = new Date(importData.started_at);
|
||||
const end = new Date(importData.completed_at);
|
||||
const diffMs = end - start;
|
||||
const minutes = Math.floor(diffMs / 60000);
|
||||
const seconds = Math.floor((diffMs % 60000) / 1000);
|
||||
|
||||
if (diffMs < 5000 && importData.import_type === 'complete_workflow') {
|
||||
const estimatedSeconds = Math.round((importData.total_tlds || 0) * 1.1);
|
||||
const estMinutes = Math.floor(estimatedSeconds / 60);
|
||||
const estSeconds = estimatedSeconds % 60;
|
||||
duration = `~${estMinutes} minutes ${estSeconds} seconds (estimated)`;
|
||||
} else if (minutes === 0 && seconds === 0) {
|
||||
duration = 'Less than 1 second';
|
||||
} else {
|
||||
duration = `${minutes} minutes ${seconds} seconds`;
|
||||
}
|
||||
}
|
||||
|
||||
let statusClass = 'bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-200';
|
||||
let statusText = 'Unknown';
|
||||
if (importData.status === 'completed') {
|
||||
statusClass = 'bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400';
|
||||
statusText = 'Completed';
|
||||
} else if (importData.status === 'failed') {
|
||||
statusClass = 'bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400';
|
||||
statusText = 'Failed';
|
||||
} else if (importData.status === 'running') {
|
||||
statusClass = 'bg-yellow-100 dark:bg-yellow-500/10 text-yellow-800 dark:text-yellow-400';
|
||||
statusText = 'Running';
|
||||
}
|
||||
|
||||
document.getElementById('importDetailsContent').innerHTML = `
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">Import ID:</span>
|
||||
<span class="text-gray-700 dark:text-slate-300">${importData.id}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">Type:</span>
|
||||
<span class="text-gray-700 dark:text-slate-300">${typeLabel}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">Description:</span>
|
||||
<span class="text-gray-600 dark:text-slate-400">${typeDescription}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">Status:</span>
|
||||
<span class="px-2 py-1 rounded text-xs ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">Duration:</span>
|
||||
<span class="text-gray-700 dark:text-slate-300">${duration}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">Started:</span>
|
||||
<span class="text-gray-700 dark:text-slate-300">${new Date(importData.started_at).toLocaleString()}</span>
|
||||
</div>
|
||||
${importData.completed_at ? `
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">Completed:</span>
|
||||
<span class="text-gray-700 dark:text-slate-300">${new Date(importData.completed_at).toLocaleString()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${importData.iana_publication_date ? `
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-white">IANA Publication:</span>
|
||||
<span class="text-gray-700 dark:text-slate-300">${importData.iana_publication_date}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="mt-4">
|
||||
<h4 class="font-medium text-gray-900 dark:text-white mb-2">Import Results:</h4>
|
||||
<div class="bg-gray-100 dark:bg-slate-900 p-3 rounded text-xs font-mono space-y-1 text-gray-800 dark:text-slate-300">
|
||||
<div>Total TLDs: ${importData.total_tlds || 0}</div>
|
||||
<div>New TLDs: ${importData.new_tlds || 0}</div>
|
||||
<div>Updated TLDs: ${importData.updated_tlds || 0}</div>
|
||||
<div>Failed TLDs: ${importData.failed_tlds || 0}</div>
|
||||
${importData.error_message ? `
|
||||
<div class="text-red-600 dark:text-red-400 mt-2">
|
||||
<strong>Error:</strong> ${importData.error_message}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('importDetailsModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function findImportData(importId) {
|
||||
const importRows = document.querySelectorAll('tr[data-import-id]');
|
||||
for (let row of importRows) {
|
||||
if (row.getAttribute('data-import-id') == importId) {
|
||||
return JSON.parse(row.getAttribute('data-import-data'));
|
||||
}
|
||||
}
|
||||
|
||||
const importCards = document.querySelectorAll('[data-import-id]');
|
||||
for (let card of importCards) {
|
||||
if (card.getAttribute('data-import-id') == importId) {
|
||||
return JSON.parse(card.getAttribute('data-import-data'));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function closeImportDetails() {
|
||||
document.getElementById('importDetailsModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('importDetailsModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeImportDetails();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,135 +1,134 @@
|
||||
<?php
|
||||
$title = $title ?? 'Import Progress';
|
||||
$pageTitle = $title;
|
||||
$pageDescription = 'Progressive data import with real-time progress';
|
||||
$pageIcon = 'fas fa-tasks';
|
||||
ob_start();
|
||||
?>
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = title|default('Import Progress') %}
|
||||
{% set pageTitle = title %}
|
||||
{% set pageDescription = 'Progressive data import with real-time progress' %}
|
||||
{% set pageIcon = 'fas fa-tasks' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
{# Header #}
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900"><?= htmlspecialchars($title) ?></h1>
|
||||
<p class="text-gray-600 mt-1">
|
||||
<?php
|
||||
$descriptions = [
|
||||
'tld_list' => 'Importing complete TLD list from IANA',
|
||||
'rdap' => 'Importing RDAP servers for existing TLDs',
|
||||
'whois' => 'Importing WHOIS & Registry data via RDAP API (with HTML fallback)',
|
||||
'check_updates' => 'Checking for IANA updates',
|
||||
'complete_workflow' => 'Complete TLD import workflow (TLD List → RDAP → WHOIS & Registry Data)'
|
||||
];
|
||||
echo htmlspecialchars($descriptions[$import_type] ?? 'Processing import');
|
||||
?>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ title }}</h1>
|
||||
<p class="text-gray-600 dark:text-slate-400 mt-1">
|
||||
{% set descriptions = {
|
||||
tld_list: 'Importing complete TLD list from IANA',
|
||||
rdap: 'Importing RDAP servers for existing TLDs',
|
||||
whois: 'Importing WHOIS & Registry data via RDAP API (with HTML fallback)',
|
||||
check_updates: 'Checking for IANA updates',
|
||||
complete_workflow: 'Complete TLD import workflow (TLD List → RDAP → WHOIS & Registry Data)'
|
||||
} %}
|
||||
{{ descriptions[import_type]|default('Processing import') }}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/tld-registry" class="inline-flex items-center px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<a href="/tld-registry" class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to TLD Registry
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
||||
{# Progress Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Import Status</h2>
|
||||
<div id="status-badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Import Status</h2>
|
||||
<div id="status-badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 dark:bg-yellow-500/10 text-yellow-800 dark:text-yellow-400">
|
||||
<i class="fas fa-clock mr-2"></i>
|
||||
<span id="status-text">Starting...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
{# Progress Bar #}
|
||||
<div class="mb-4">
|
||||
<div class="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<div class="flex justify-between text-sm text-gray-600 dark:text-slate-400 mb-2">
|
||||
<span id="progress-text">0 of 0 items processed</span>
|
||||
<span id="percentage-text">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||
<div class="w-full bg-gray-200 dark:bg-slate-700 rounded-full h-3">
|
||||
<div id="progress-bar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Progress (for complete workflow) -->
|
||||
{# Step Progress (for complete workflow) #}
|
||||
<div id="step-progress" class="mb-4" style="display: none;">
|
||||
<div class="text-sm text-gray-600 mb-2">Workflow Steps:</div>
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400 mb-2">Workflow Steps:</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="step-item bg-gray-100 rounded-lg p-2 text-center">
|
||||
<div class="text-xs font-medium text-gray-600">Step 1</div>
|
||||
<div class="text-xs text-gray-500">TLD List</div>
|
||||
<div id="step-1-status" class="text-xs text-gray-400">Pending</div>
|
||||
<div class="step-item bg-gray-100 dark:bg-slate-700 rounded-lg p-2 text-center">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-slate-400">Step 1</div>
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">TLD List</div>
|
||||
<div id="step-1-status" class="text-xs text-gray-400 dark:text-slate-500">Pending</div>
|
||||
</div>
|
||||
<div class="step-item bg-gray-100 rounded-lg p-2 text-center">
|
||||
<div class="text-xs font-medium text-gray-600">Step 2</div>
|
||||
<div class="text-xs text-gray-500">RDAP</div>
|
||||
<div id="step-2-status" class="text-xs text-gray-400">Pending</div>
|
||||
<div class="step-item bg-gray-100 dark:bg-slate-700 rounded-lg p-2 text-center">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-slate-400">Step 2</div>
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">RDAP</div>
|
||||
<div id="step-2-status" class="text-xs text-gray-400 dark:text-slate-500">Pending</div>
|
||||
</div>
|
||||
<div class="step-item bg-gray-100 rounded-lg p-2 text-center">
|
||||
<div class="text-xs font-medium text-gray-600">Step 3</div>
|
||||
<div class="text-xs text-gray-500">WHOIS & Registry</div>
|
||||
<div id="step-3-status" class="text-xs text-gray-400">Pending</div>
|
||||
<div class="step-item bg-gray-100 dark:bg-slate-700 rounded-lg p-2 text-center">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-slate-400">Step 3</div>
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">WHOIS & Registry</div>
|
||||
<div id="step-3-status" class="text-xs text-gray-400 dark:text-slate-500">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
{# Statistics #}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-list text-blue-600"></i>
|
||||
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-500/10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-list text-blue-600 dark:text-blue-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Total</p>
|
||||
<p id="total-count" class="text-xl font-semibold text-gray-900">0</p>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">Total</p>
|
||||
<p id="total-count" class="text-xl font-semibold text-gray-900 dark:text-white">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-check text-green-600"></i>
|
||||
<div class="w-10 h-10 bg-green-100 dark:bg-green-500/10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-check text-green-600 dark:text-green-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Processed</p>
|
||||
<p id="processed-count" class="text-xl font-semibold text-gray-900">0</p>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">Processed</p>
|
||||
<p id="processed-count" class="text-xl font-semibold text-gray-900 dark:text-white">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-times text-red-600"></i>
|
||||
<div class="w-10 h-10 bg-red-100 dark:bg-red-500/10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-times text-red-600 dark:text-red-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Failed</p>
|
||||
<p id="failed-count" class="text-xl font-semibold text-gray-900">0</p>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">Failed</p>
|
||||
<p id="failed-count" class="text-xl font-semibold text-gray-900 dark:text-white">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-hourglass-half text-orange-600"></i>
|
||||
<div class="w-10 h-10 bg-orange-100 dark:bg-orange-500/10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-hourglass-half text-orange-600 dark:text-orange-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Remaining</p>
|
||||
<p id="remaining-count" class="text-xl font-semibold text-gray-900">0</p>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">Remaining</p>
|
||||
<p id="remaining-count" class="text-xl font-semibold text-gray-900 dark:text-white">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Output -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Import Log</h3>
|
||||
{# Log Output #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Import Log</h3>
|
||||
<div id="log-output" class="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm h-64 overflow-y-auto">
|
||||
<div class="text-gray-500">Initializing import process...</div>
|
||||
</div>
|
||||
@@ -137,13 +136,12 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let logId = <?= json_encode($log_id) ?>;
|
||||
let importType = <?= json_encode($import_type) ?>;
|
||||
let logId = {{ log_id|json_encode|raw }};
|
||||
let importType = {{ import_type|json_encode|raw }};
|
||||
let isComplete = false;
|
||||
let totalProcessed = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
// Show step progress for complete workflow
|
||||
if (importType === 'complete_workflow') {
|
||||
document.getElementById('step-progress').style.display = 'block';
|
||||
}
|
||||
@@ -152,11 +150,11 @@ function addLogMessage(message, type = 'info') {
|
||||
const logOutput = document.getElementById('log-output');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const colorClass = type === 'error' ? 'text-red-400' : type === 'success' ? 'text-green-400' : 'text-blue-400';
|
||||
|
||||
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = colorClass;
|
||||
logEntry.innerHTML = `[${timestamp}] ${message}`;
|
||||
|
||||
|
||||
logOutput.appendChild(logEntry);
|
||||
logOutput.scrollTop = logOutput.scrollHeight;
|
||||
}
|
||||
@@ -166,51 +164,45 @@ function updateProgress(data) {
|
||||
const processed = data.processed || 0;
|
||||
const failed = data.failed || 0;
|
||||
const remaining = data.remaining || 0;
|
||||
|
||||
// Update counts (use absolute values, not cumulative)
|
||||
|
||||
document.getElementById('total-count').textContent = total;
|
||||
document.getElementById('processed-count').textContent = processed;
|
||||
document.getElementById('failed-count').textContent = failed;
|
||||
document.getElementById('remaining-count').textContent = remaining;
|
||||
|
||||
// Update progress bar
|
||||
|
||||
const totalToProcess = processed + remaining;
|
||||
const percentage = totalToProcess > 0 ? Math.round((processed / totalToProcess) * 100) : 0;
|
||||
|
||||
|
||||
document.getElementById('progress-bar').style.width = percentage + '%';
|
||||
document.getElementById('progress-text').textContent = `${processed} of ${totalToProcess} items processed`;
|
||||
document.getElementById('percentage-text').textContent = percentage + '%';
|
||||
|
||||
// Update step progress for complete workflow
|
||||
|
||||
if (importType === 'complete_workflow' && data.message) {
|
||||
updateStepProgress(data.message, processed, total);
|
||||
}
|
||||
|
||||
// Update status
|
||||
const statusBadge = document.getElementById('status-badge');
|
||||
const statusText = document.getElementById('status-text');
|
||||
|
||||
|
||||
if (data.status === 'complete') {
|
||||
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800';
|
||||
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400';
|
||||
statusText.innerHTML = '<i class="fas fa-check mr-2"></i>Complete';
|
||||
isComplete = true;
|
||||
|
||||
// Show the actual completion message from API
|
||||
|
||||
const completionMessage = data.message || 'Import completed successfully!';
|
||||
addLogMessage(completionMessage, 'success');
|
||||
|
||||
// Mark all steps as completed for complete workflow
|
||||
|
||||
if (importType === 'complete_workflow') {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
updateStepStatus(i, 'completed');
|
||||
}
|
||||
}
|
||||
} else if (data.status === 'in_progress') {
|
||||
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800';
|
||||
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400';
|
||||
statusText.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>In Progress';
|
||||
addLogMessage(data.message || 'Processing batch...', 'info');
|
||||
} else if (data.status === 'error') {
|
||||
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800';
|
||||
statusBadge.className = 'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400';
|
||||
statusText.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>Error';
|
||||
addLogMessage(data.message || 'An error occurred', 'error');
|
||||
isComplete = true;
|
||||
@@ -221,10 +213,9 @@ function checkProgress() {
|
||||
if (isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
fetch(`/tld-registry/api/import-progress?log_id=${logId}`)
|
||||
.then(response => {
|
||||
// Check if response is actually JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return response.text().then(text => {
|
||||
@@ -242,17 +233,17 @@ function checkProgress() {
|
||||
isComplete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
updateProgress(data);
|
||||
|
||||
|
||||
if (data.status !== 'complete' && data.status !== 'error') {
|
||||
setTimeout(checkProgress, 2000); // Check again in 2 seconds
|
||||
setTimeout(checkProgress, 2000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.message.includes('Gateway Timeout') || error.message.includes('timeout')) {
|
||||
addLogMessage('Gateway timeout detected. Retrying in 5 seconds...', 'warning');
|
||||
setTimeout(checkProgress, 5000); // Retry after 5 seconds
|
||||
setTimeout(checkProgress, 5000);
|
||||
} else {
|
||||
addLogMessage('Network error: ' + error.message, 'error');
|
||||
isComplete = true;
|
||||
@@ -261,33 +252,26 @@ function checkProgress() {
|
||||
}
|
||||
|
||||
function updateStepProgress(message, currentStep, totalSteps) {
|
||||
// Extract step number from message (handle both /3 and /4 formats)
|
||||
const stepMatch = message.match(/Step (\d+)\/(\d+)/);
|
||||
if (stepMatch) {
|
||||
const stepNumber = parseInt(stepMatch[1]);
|
||||
const totalSteps = parseInt(stepMatch[2]);
|
||||
|
||||
// Check if this step is completed
|
||||
|
||||
const isCompleted = message.toLowerCase().includes('completed');
|
||||
|
||||
|
||||
if (isCompleted) {
|
||||
// Mark all steps up to and including this one as completed
|
||||
for (let i = 1; i <= stepNumber; i++) {
|
||||
updateStepStatus(i, 'completed');
|
||||
}
|
||||
|
||||
// Mark next step as in progress if not the last step
|
||||
|
||||
if (stepNumber < totalSteps) {
|
||||
updateStepStatus(stepNumber + 1, 'in_progress');
|
||||
}
|
||||
} else {
|
||||
// Step is in progress
|
||||
// Mark previous steps as completed
|
||||
for (let i = 1; i < stepNumber; i++) {
|
||||
updateStepStatus(i, 'completed');
|
||||
}
|
||||
|
||||
// Mark current step as in progress
|
||||
|
||||
updateStepStatus(stepNumber, 'in_progress');
|
||||
}
|
||||
}
|
||||
@@ -296,27 +280,23 @@ function updateStepProgress(message, currentStep, totalSteps) {
|
||||
function updateStepStatus(stepNumber, status) {
|
||||
const stepElement = document.getElementById(`step-${stepNumber}-status`);
|
||||
const stepItem = stepElement.closest('.step-item');
|
||||
|
||||
|
||||
if (status === 'completed') {
|
||||
stepElement.textContent = 'Completed';
|
||||
stepElement.className = 'text-xs text-green-600';
|
||||
stepItem.className = 'step-item bg-green-100 rounded-lg p-2 text-center';
|
||||
stepElement.className = 'text-xs text-green-600 dark:text-green-400';
|
||||
stepItem.className = 'step-item bg-green-100 dark:bg-green-500/10 rounded-lg p-2 text-center';
|
||||
} else if (status === 'in_progress') {
|
||||
stepElement.textContent = 'In Progress';
|
||||
stepElement.className = 'text-xs text-blue-600';
|
||||
stepItem.className = 'step-item bg-blue-100 rounded-lg p-2 text-center';
|
||||
stepElement.className = 'text-xs text-blue-600 dark:text-blue-400';
|
||||
stepItem.className = 'step-item bg-blue-100 dark:bg-blue-500/10 rounded-lg p-2 text-center';
|
||||
} else if (status === 'failed') {
|
||||
stepElement.textContent = 'Failed';
|
||||
stepElement.className = 'text-xs text-red-600';
|
||||
stepItem.className = 'step-item bg-red-100 rounded-lg p-2 text-center';
|
||||
stepElement.className = 'text-xs text-red-600 dark:text-red-400';
|
||||
stepItem.className = 'step-item bg-red-100 dark:bg-red-500/10 rounded-lg p-2 text-center';
|
||||
}
|
||||
}
|
||||
|
||||
// Start checking progress
|
||||
checkProgress();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
@@ -1,894 +0,0 @@
|
||||
<?php
|
||||
$title = 'TLD Registry';
|
||||
$pageTitle = 'TLD Registry';
|
||||
$pageDescription = 'Manage Top-Level Domain registry information';
|
||||
$pageIcon = 'fas fa-database';
|
||||
ob_start();
|
||||
|
||||
// Helper function to generate sort URL
|
||||
function sortUrl($column, $currentSort, $currentOrder) {
|
||||
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
|
||||
$params = $_GET;
|
||||
$params['sort'] = $column;
|
||||
$params['order'] = $newOrder;
|
||||
return '/tld-registry?' . http_build_query($params);
|
||||
}
|
||||
|
||||
// Helper function for sort icon
|
||||
function sortIcon($column, $currentSort, $currentOrder) {
|
||||
if ($currentSort !== $column) {
|
||||
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
|
||||
}
|
||||
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
|
||||
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
|
||||
}
|
||||
|
||||
// Get current filters
|
||||
$currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'];
|
||||
?>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<div class="mb-4 flex flex-wrap gap-2 justify-end">
|
||||
<!-- IANA Dropdown -->
|
||||
<div class="relative" id="ianaDropdownWrapper">
|
||||
<button onclick="document.getElementById('ianaDropdownMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 transition-colors font-medium">
|
||||
<i class="fas fa-globe mr-2"></i>
|
||||
IANA
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="ianaDropdownMenu" class="hidden absolute right-0 mt-1 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
|
||||
<form method="POST" action="/tld-registry/start-progressive-import">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="import_type" value="complete_workflow">
|
||||
<button type="submit" class="w-full flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
||||
<i class="fas fa-rocket text-indigo-600 mr-2.5"></i>
|
||||
Import TLDs from IANA
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/tld-registry/start-progressive-import">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="import_type" value="check_updates">
|
||||
<button type="submit" <?= $tldStats['total'] == 0 ? 'disabled' : '' ?> class="w-full flex items-center px-4 py-2.5 text-sm <?= $tldStats['total'] == 0 ? 'text-gray-400 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50' ?> transition-colors border-t border-gray-100" title="<?= $tldStats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
|
||||
<i class="fas fa-sync-alt text-blue-600 mr-2.5"></i>
|
||||
Check for Updates
|
||||
</button>
|
||||
</form>
|
||||
<a href="/tld-registry/import-logs" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
|
||||
<i class="fas fa-history text-gray-500 mr-2.5"></i>
|
||||
IANA Import Logs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Export Dropdown -->
|
||||
<div class="relative" id="tldExportDropdownWrapper">
|
||||
<button onclick="document.getElementById('tldExportDropdownMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Export
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="tldExportDropdownMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
|
||||
<a href="/tld-registry/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
|
||||
Export as CSV
|
||||
</a>
|
||||
<a href="/tld-registry/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
|
||||
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
|
||||
Export as JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Import Button -->
|
||||
<button onclick="document.getElementById('tldImportModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import
|
||||
</button>
|
||||
<!-- Create Button -->
|
||||
<button onclick="openCreateTldModal()" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create TLD
|
||||
</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-yellow-600 mr-2"></i>
|
||||
<p class="text-sm text-yellow-800">
|
||||
View-only mode. Contact admin to import or modify TLD data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Total TLDs Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total TLDs</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $tldStats['total'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-globe text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active TLDs Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Active</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $tldStats['active'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-check-circle text-green-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- With RDAP Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">With RDAP</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $tldStats['with_rdap'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-indigo-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-database text-indigo-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- With WHOIS Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">With WHOIS</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $tldStats['with_whois'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-server text-orange-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<form method="GET" action="/tld-registry" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search TLDs</label>
|
||||
<div class="relative">
|
||||
<input type="text" name="search" id="tldSearch" value="<?= htmlspecialchars($currentFilters['search']) ?>" placeholder="Search TLDs..." class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
|
||||
<select name="status" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">All Status</option>
|
||||
<option value="active" <?= ($_GET['status'] ?? '') === 'active' ? 'selected' : '' ?>>Active</option>
|
||||
<option value="inactive" <?= ($_GET['status'] ?? '') === 'inactive' ? 'selected' : '' ?>>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Data Type Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Data Type</label>
|
||||
<select name="data_type" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">All Types</option>
|
||||
<option value="with_rdap" <?= ($_GET['data_type'] ?? '') === 'with_rdap' ? 'selected' : '' ?>>With RDAP</option>
|
||||
<option value="with_whois" <?= ($_GET['data_type'] ?? '') === 'with_whois' ? 'selected' : '' ?>>With WHOIS</option>
|
||||
<option value="with_registry" <?= ($_GET['data_type'] ?? '') === 'with_registry' ? 'selected' : '' ?>>With Registry URL</option>
|
||||
<option value="missing_data" <?= ($_GET['data_type'] ?? '') === 'missing_data' ? 'selected' : '' ?>>Missing Data</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply
|
||||
</button>
|
||||
<a href="/tld-registry" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?></span> TLD(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/tld-registry" class="flex items-center gap-2">
|
||||
<!-- Preserve current filters -->
|
||||
<input type="hidden" name="search" value="<?= htmlspecialchars($currentFilters['search']) ?>">
|
||||
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="10" <?= $pagination['per_page'] == 10 ? 'selected' : '' ?>>10</option>
|
||||
<option value="25" <?= $pagination['per_page'] == 25 ? 'selected' : '' ?>>25</option>
|
||||
<option value="50" <?= $pagination['per_page'] == 50 ? 'selected' : '' ?>>50</option>
|
||||
<option value="100" <?= $pagination['per_page'] == 100 ? 'selected' : '' ?>>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- TLD Registry Table -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<!-- Bulk Actions Bar (shown when TLDs are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($tlds)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<input type="checkbox" id="select-all" class="rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllCheckboxes(this)">
|
||||
</th>
|
||||
<?php endif; ?>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('tld', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
TLD <?= sortIcon('tld', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('rdap_servers', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
RDAP Servers <?= sortIcon('rdap_servers', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('whois_server', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
WHOIS Server <?= sortIcon('whois_server', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('updated_at', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
Last Updated <?= sortIcon('updated_at', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('is_active', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
Status <?= sortIcon('is_active', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($tlds as $tld): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150">
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input type="checkbox" name="tld_ids[]" value="<?= $tld['id'] ?>" class="tld-checkbox rounded border-gray-300 text-primary focus:ring-primary">
|
||||
</td>
|
||||
<?php endif; ?>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($tld['tld']) ?></div>
|
||||
<?php if ($tld['registry_url']): ?>
|
||||
<div class="text-sm text-gray-500">
|
||||
<a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>
|
||||
Registry
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<?php if ($tld['rdap_servers']): ?>
|
||||
<?php
|
||||
$rdapServers = json_decode($tld['rdap_servers'], true);
|
||||
if (is_array($rdapServers) && !empty($rdapServers)):
|
||||
?>
|
||||
<div class="text-sm text-gray-900">
|
||||
<?php foreach (array_slice($rdapServers, 0, 2) as $server): ?>
|
||||
<div class="font-mono text-xs bg-gray-50 px-2 py-1 rounded mb-1"><?= htmlspecialchars($server) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (count($rdapServers) > 2): ?>
|
||||
<div class="text-xs text-gray-500">+<?= count($rdapServers) - 2 ?> more</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-sm text-gray-400">None</span>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<span class="text-sm text-gray-400">None</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<?php if ($tld['whois_server']): ?>
|
||||
<div class="text-sm font-mono text-gray-900 bg-gray-50 px-2 py-1 rounded"><?= htmlspecialchars($tld['whois_server']) ?></div>
|
||||
<?php else: ?>
|
||||
<span class="text-sm text-gray-400">None</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<?php if ($tld['updated_at']): ?>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
<?= date('M d, H:i', strtotime($tld['updated_at'])) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-gray-400">Never</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border <?= $tld['is_active'] ? 'bg-green-100 text-green-700 border-green-200' : 'bg-gray-100 text-gray-700 border-gray-200' ?>">
|
||||
<i class="fas <?= $tld['is_active'] ? 'fa-check-circle' : 'fa-pause-circle' ?> mr-1"></i>
|
||||
<?= $tld['is_active'] ? 'Active' : 'Inactive' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/tld-registry/<?= $tld['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirm('Refresh TLD data from IANA?')">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</a>
|
||||
<a href="/tld-registry/<?= $tld['id'] ?>/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirm('Toggle TLD status?')">
|
||||
<i class="fas fa-power-off"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Card View (Mobile) -->
|
||||
<div class="lg:hidden divide-y divide-gray-200">
|
||||
<?php foreach ($tlds as $tld): ?>
|
||||
<div class="p-6 hover:bg-gray-50 transition-colors duration-150">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900"><?= htmlspecialchars($tld['tld']) ?></h3>
|
||||
<?php if ($tld['registry_url']): ?>
|
||||
<a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="text-xs text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>
|
||||
Registry
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold <?= $tld['is_active'] ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700' ?>">
|
||||
<?= $tld['is_active'] ? 'Active' : 'Inactive' ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<?php if ($tld['rdap_servers']): ?>
|
||||
<?php
|
||||
$rdapServers = json_decode($tld['rdap_servers'], true);
|
||||
if (is_array($rdapServers) && !empty($rdapServers)):
|
||||
?>
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-database text-gray-400 mr-2 w-4 mt-0.5"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-mono text-xs bg-gray-50 px-2 py-1 rounded mb-1"><?= htmlspecialchars($rdapServers[0]) ?></div>
|
||||
<?php if (count($rdapServers) > 1): ?>
|
||||
<div class="text-xs text-gray-500">+<?= count($rdapServers) - 1 ?> more RDAP server(s)</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($tld['whois_server']): ?>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-server text-gray-400 mr-2 w-4"></i>
|
||||
<span class="font-mono text-xs bg-gray-50 px-2 py-1 rounded"><?= htmlspecialchars($tld['whois_server']) ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock text-gray-400 mr-2 w-4"></i>
|
||||
<span class="text-gray-500"><?= $tld['updated_at'] ? date('M d, H:i', strtotime($tld['updated_at'])) : 'Never updated' ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 mt-3">
|
||||
<a href="/tld-registry/<?= $tld['id'] ?>" class="<?= (isset($_SESSION['role']) && $_SESSION['role'] === 'admin') ? 'flex-1' : 'w-full' ?> px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i> View
|
||||
</a>
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="flex-1 px-3 py-1.5 bg-green-50 text-green-600 rounded text-center text-sm hover:bg-green-100 transition-colors" onclick="return confirm('Refresh TLD data?')">
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-globe text-gray-300 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-1">No TLDs Found</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
<?php if (!empty($currentFilters['search'])): ?>
|
||||
No TLDs match your search criteria.
|
||||
<?php else: ?>
|
||||
Start by importing the TLD list from IANA.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php if (empty($currentFilters['search'])): ?>
|
||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="import_type" value="complete_workflow">
|
||||
<button type="submit" class="inline-flex items-center px-5 py-2.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-rocket mr-2"></i>
|
||||
Import TLDs
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<?php if ($pagination['total_pages'] > 1): ?>
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Page Info -->
|
||||
<div class="text-sm text-gray-600">
|
||||
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?></span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
<?php
|
||||
$currentPage = $pagination['current_page'];
|
||||
$totalPages = $pagination['total_pages'];
|
||||
|
||||
// Helper function to build pagination URL
|
||||
function paginationUrl($page, $filters, $perPage) {
|
||||
$params = $filters;
|
||||
$params['page'] = $page;
|
||||
$params['per_page'] = $perPage;
|
||||
return '/tld-registry?' . http_build_query($params);
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- First Page -->
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl(1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Previous Page -->
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl($currentPage - 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Page Numbers -->
|
||||
<?php
|
||||
$range = 2; // Show 2 pages on each side of current page
|
||||
$start = max(1, $currentPage - $range);
|
||||
$end = min($totalPages, $currentPage + $range);
|
||||
|
||||
// Show first page + ellipsis if needed
|
||||
if ($start > 1) {
|
||||
echo '<a href="' . paginationUrl(1, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
|
||||
if ($start > 2) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// Page numbers
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
if ($i == $currentPage) {
|
||||
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
|
||||
} else {
|
||||
echo '<a href="' . paginationUrl($i, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
// Show last page + ellipsis if needed
|
||||
if ($end < $totalPages) {
|
||||
if ($end < $totalPages - 1) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
echo '<a href="' . paginationUrl($totalPages, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Next Page -->
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($currentPage + 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Last Page -->
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($totalPages, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Create TLD Modal -->
|
||||
<div id="createTldModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full">
|
||||
<form method="POST" action="/tld-registry/create">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Create New TLD</h3>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label for="create_tld" class="block text-sm font-medium text-gray-700 mb-1">TLD Name</label>
|
||||
<input type="text" id="create_tld" name="tld" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g., .com, .xyz, .co.uk">
|
||||
<p class="text-xs text-gray-500 mt-1">The dot prefix will be added automatically. Multi-level TLDs supported (e.g., co.uk, com.au)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="create_whois_server" class="block text-sm font-medium text-gray-700 mb-1">WHOIS Server (Optional)</label>
|
||||
<input type="text" id="create_whois_server" name="whois_server"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g., whois.verisign-grs.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="create_rdap_servers" class="block text-sm font-medium text-gray-700 mb-1">RDAP Servers (Optional)</label>
|
||||
<textarea id="create_rdap_servers" name="rdap_servers" rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g., https://rdap.verisign.com/com/v1/"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1">One URL per line or comma-separated</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="create_registry_url" class="block text-sm font-medium text-gray-700 mb-1">Registry URL (Optional)</label>
|
||||
<input type="url" id="create_registry_url" name="registry_url"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g., https://www.verisign.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-gray-50 flex justify-end space-x-3">
|
||||
<button type="button" onclick="closeCreateTldModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
|
||||
Create TLD
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import TLD Modal -->
|
||||
<div id="tldImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>Import TLDs
|
||||
</h3>
|
||||
<button onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tld-registry/import" enctype="multipart/form-data" id="tldImportForm">
|
||||
<?= csrf_field() ?>
|
||||
<div class="p-6 space-y-4">
|
||||
<!-- Drag & Drop Zone -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Select File</label>
|
||||
<div id="tldDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="tldFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="tldDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
</div>
|
||||
<div id="tldDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700" id="tldFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="tldFileSize"></p>
|
||||
<button type="button" id="tldFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600">CSV columns: <code class="bg-white px-1 rounded">tld, whois_server, rdap_servers, registry_url, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 mt-0.5">JSON: array of objects with same fields</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Existing TLDs will be updated. New TLDs will be created as active.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="tldImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import TLDs
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleAllCheckboxes(selectAllCheckbox) {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = selectAllCheckbox.checked;
|
||||
});
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||
const count = checkboxes.length;
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
selectedCount.textContent = count + ' TLD(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.tld-checkbox');
|
||||
if (selectAllCheckbox) {
|
||||
if (count === 0) {
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
selectAllCheckbox.checked = false;
|
||||
} else if (count === allCheckboxes.length) {
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
selectAllCheckbox.checked = true;
|
||||
} else {
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = false;
|
||||
}
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('Please select TLDs to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Are you sure you want to delete ${checkboxes.length} selected TLD(s)? This action cannot be undone.`)) {
|
||||
// Add selected checkboxes to form
|
||||
const form = document.getElementById('bulk-delete-form');
|
||||
checkboxes.forEach(checkbox => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'tld_ids[]';
|
||||
input.value = checkbox.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners to checkboxes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', updateSelectedCount);
|
||||
});
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
// Create TLD Modal
|
||||
function openCreateTldModal() {
|
||||
document.getElementById('createTldModal').classList.remove('hidden');
|
||||
document.getElementById('create_tld').focus();
|
||||
}
|
||||
|
||||
function closeCreateTldModal() {
|
||||
document.getElementById('createTldModal').classList.add('hidden');
|
||||
document.querySelector('#createTldModal form').reset();
|
||||
}
|
||||
|
||||
document.getElementById('createTldModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateTldModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Import Modal
|
||||
document.getElementById('tldImportModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
document.getElementById('tldImportModal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const exportWrapper = document.getElementById('tldExportDropdownWrapper');
|
||||
if (exportWrapper && !exportWrapper.contains(e.target)) {
|
||||
document.getElementById('tldExportDropdownMenu').classList.add('hidden');
|
||||
}
|
||||
const ianaWrapper = document.getElementById('ianaDropdownWrapper');
|
||||
if (ianaWrapper && !ianaWrapper.contains(e.target)) {
|
||||
document.getElementById('ianaDropdownMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Close modals on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateTldModal();
|
||||
document.getElementById('tldImportModal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Import drag-and-drop & loading
|
||||
(function() {
|
||||
const dropzone = document.getElementById('tldDropzone');
|
||||
const fileInput = document.getElementById('tldFileInput');
|
||||
const content = document.getElementById('tldDropzoneContent');
|
||||
const fileInfo = document.getElementById('tldDropzoneFile');
|
||||
const fileName = document.getElementById('tldFileName');
|
||||
const fileSize = document.getElementById('tldFileSize');
|
||||
const removeBtn = document.getElementById('tldFileRemove');
|
||||
const form = document.getElementById('tldImportForm');
|
||||
const submitBtn = document.getElementById('tldImportBtn');
|
||||
|
||||
if (!dropzone) return;
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
842
app/Views/tld-registry/index.twig
Normal file
842
app/Views/tld-registry/index.twig
Normal file
@@ -0,0 +1,842 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'TLD Registry' %}
|
||||
{% set pageTitle = 'TLD Registry' %}
|
||||
{% set pageDescription = 'Manage Top-Level Domain registry information' %}
|
||||
{% set pageIcon = 'fas fa-database' %}
|
||||
|
||||
{% set currentFilters = filters|default({search: '', sort: 'tld', order: 'asc'}) %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# Action Buttons #}
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<div class="mb-4 flex flex-wrap gap-2 justify-end">
|
||||
{# IANA Dropdown #}
|
||||
<div class="relative" id="ianaDropdownWrapper">
|
||||
<button onclick="document.getElementById('ianaDropdownMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 transition-colors font-medium">
|
||||
<i class="fas fa-globe mr-2"></i>
|
||||
IANA
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="ianaDropdownMenu" class="hidden absolute right-0 mt-1 w-56 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-gray-200 dark:border-slate-700 z-30 overflow-hidden">
|
||||
<form method="POST" action="/tld-registry/start-progressive-import">
|
||||
{{ csrf_field() }}
|
||||
<input type="hidden" name="import_type" value="complete_workflow">
|
||||
<button type="submit" class="w-full flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
||||
<i class="fas fa-rocket text-indigo-600 mr-2.5"></i>
|
||||
Import TLDs from IANA
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/tld-registry/start-progressive-import">
|
||||
{{ csrf_field() }}
|
||||
<input type="hidden" name="import_type" value="check_updates">
|
||||
<button type="submit" {{ tldStats.total == 0 ? 'disabled' : '' }} class="w-full flex items-center px-4 py-2.5 text-sm {{ tldStats.total == 0 ? 'text-gray-400 dark:text-slate-500 cursor-not-allowed' : 'text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700' }} transition-colors border-t border-gray-100 dark:border-slate-700" title="{{ tldStats.total == 0 ? 'Import TLDs first' : 'Check for IANA updates' }}">
|
||||
<i class="fas fa-sync-alt text-blue-600 mr-2.5"></i>
|
||||
Check for Updates
|
||||
</button>
|
||||
</form>
|
||||
<a href="/tld-registry/import-logs" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors border-t border-gray-100 dark:border-slate-700">
|
||||
<i class="fas fa-history text-gray-500 dark:text-slate-400 mr-2.5"></i>
|
||||
IANA Import Logs
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{# Export Dropdown #}
|
||||
<div class="relative" id="tldExportDropdownWrapper">
|
||||
<button onclick="document.getElementById('tldExportDropdownMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Export
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="tldExportDropdownMenu" class="hidden absolute right-0 mt-1 w-44 bg-white dark:bg-slate-800 rounded-lg shadow-lg border border-gray-200 dark:border-slate-700 z-30 overflow-hidden">
|
||||
<a href="/tld-registry/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
|
||||
Export as CSV
|
||||
</a>
|
||||
<a href="/tld-registry/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors border-t border-gray-100 dark:border-slate-700">
|
||||
<i class="fas fa-file-code text-blue-600 mr-2.5"></i>
|
||||
Export as JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{# Import Button #}
|
||||
<button onclick="document.getElementById('tldImportModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import
|
||||
</button>
|
||||
{# Create Button #}
|
||||
<button onclick="openCreateTldModal()" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Create TLD
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-4 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-yellow-600 mr-2"></i>
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-400">
|
||||
View-only mode. Contact admin to import or modify TLD data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Statistics Cards #}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{# Total TLDs Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Total TLDs</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ tldStats.total|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-50 dark:bg-blue-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-globe text-blue-600 dark:text-blue-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active TLDs Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">Active</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ tldStats.active|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-50 dark:bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-check-circle text-green-600 dark:text-green-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# With RDAP Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">With RDAP</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ tldStats.with_rdap|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-indigo-50 dark:bg-indigo-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-database text-indigo-600 dark:text-indigo-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# With WHOIS Card #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide">With WHOIS</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ tldStats.with_whois|default(0) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-50 dark:bg-orange-500/10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-server text-orange-600 dark:text-orange-400 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Search and Filters #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 mb-4">
|
||||
<form method="GET" action="/tld-registry" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{# Search #}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Search TLDs</label>
|
||||
<div class="relative">
|
||||
<input type="text" name="search" id="tldSearch" value="{{ currentFilters.search }}" placeholder="Search TLDs..." class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Status Filter #}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Status</label>
|
||||
<select name="status" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="">All Status</option>
|
||||
<option value="active" {{ currentFilters.status|default('') == 'active' ? 'selected' : '' }}>Active</option>
|
||||
<option value="inactive" {{ currentFilters.status|default('') == 'inactive' ? 'selected' : '' }}>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Data Type Filter #}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Data Type</label>
|
||||
<select name="data_type" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="">All Types</option>
|
||||
<option value="with_rdap" {{ currentFilters.data_type|default('') == 'with_rdap' ? 'selected' : '' }}>With RDAP</option>
|
||||
<option value="with_whois" {{ currentFilters.data_type|default('') == 'with_whois' ? 'selected' : '' }}>With WHOIS</option>
|
||||
<option value="with_registry" {{ currentFilters.data_type|default('') == 'with_registry' ? 'selected' : '' }}>With Registry URL</option>
|
||||
<option value="missing_data" {{ currentFilters.data_type|default('') == 'missing_data' ? 'selected' : '' }}>Missing Data</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply
|
||||
</button>
|
||||
<a href="/tld-registry" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="sort" value="{{ currentFilters.sort }}">
|
||||
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Pagination Info & Per Page Selector #}
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_from }}</span> to
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_to }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total }}</span> TLD(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/tld-registry" class="flex items-center gap-2">
|
||||
<input type="hidden" name="search" value="{{ currentFilters.search }}">
|
||||
<input type="hidden" name="sort" value="{{ currentFilters.sort }}">
|
||||
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600 dark:text-slate-400">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="10" {{ pagination.per_page == 10 ? 'selected' : '' }}>10</option>
|
||||
<option value="25" {{ pagination.per_page == 25 ? 'selected' : '' }}>25</option>
|
||||
<option value="50" {{ pagination.per_page == 50 ? 'selected' : '' }}>50</option>
|
||||
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# TLD Registry Table #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
{# Bulk Actions Bar #}
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 dark:bg-blue-500/10 border-b border-blue-200 dark:border-blue-500/30 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700 dark:text-slate-300"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-slate-400 hover:text-gray-900 dark:hover:text-white hover:bg-blue-100 dark:hover:bg-blue-500/20 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tlds is not empty %}
|
||||
{# Table View (Desktop) #}
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<input type="checkbox" id="select-all" class="rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" onchange="toggleAllCheckboxes(this)">
|
||||
</th>
|
||||
{% endif %}
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('tld', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
TLD {{ sort_icon('tld', currentFilters.sort, currentFilters.order)|raw }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('rdap_servers', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
RDAP Servers {{ sort_icon('rdap_servers', currentFilters.sort, currentFilters.order)|raw }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('whois_server', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
WHOIS Server {{ sort_icon('whois_server', currentFilters.sort, currentFilters.order)|raw }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('updated_at', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Last Updated {{ sort_icon('updated_at', currentFilters.sort, currentFilters.order)|raw }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('is_active', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Status {{ sort_icon('is_active', currentFilters.sort, currentFilters.order)|raw }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for tld in tlds %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input type="checkbox" name="tld_ids[]" value="{{ tld.id }}" class="tld-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</div>
|
||||
{% if tld.registry_url %}
|
||||
<div class="text-sm text-gray-500 dark:text-slate-400">
|
||||
<a href="{{ tld.registry_url }}" target="_blank" class="text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>
|
||||
Registry
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{% if tld.rdap_servers %}
|
||||
{% set rdapServers = tld.rdap_servers|from_json %}
|
||||
{% if rdapServers is iterable and rdapServers is not empty %}
|
||||
<div class="text-sm text-gray-900 dark:text-white">
|
||||
{% for server in rdapServers|slice(0, 2) %}
|
||||
<div class="font-mono text-xs bg-gray-50 dark:bg-slate-700 px-2 py-1 rounded mb-1 text-gray-900 dark:text-white">{{ server }}</div>
|
||||
{% endfor %}
|
||||
{% if rdapServers|length > 2 %}
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">+{{ rdapServers|length - 2 }} more</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-400 dark:text-slate-500">None</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-400 dark:text-slate-500">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if tld.whois_server %}
|
||||
<div class="text-sm font-mono text-gray-900 dark:text-white bg-gray-50 dark:bg-slate-700 px-2 py-1 rounded">{{ tld.whois_server }}</div>
|
||||
{% else %}
|
||||
<span class="text-sm text-gray-400 dark:text-slate-500">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-slate-400">
|
||||
{% if tld.updated_at %}
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
{{ tld.updated_at|date('M d, H:i') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-slate-500">Never</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border {{ tld.is_active ? 'bg-green-100 dark:bg-green-500/10 text-green-700 dark:text-green-400 border-green-200 dark:border-green-500/30' : 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 border-gray-200 dark:border-slate-600' }}">
|
||||
<i class="fas {{ tld.is_active ? 'fa-check-circle' : 'fa-pause-circle' }} mr-1"></i>
|
||||
{{ tld.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/tld-registry/{{ tld.id }}" class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirm('Refresh TLD data from IANA?')">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</a>
|
||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirm('Toggle TLD status?')">
|
||||
<i class="fas fa-power-off"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Card View (Mobile) #}
|
||||
<div class="lg:hidden divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for tld in tlds %}
|
||||
<div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ tld.tld }}</h3>
|
||||
{% if tld.registry_url %}
|
||||
<a href="{{ tld.registry_url }}" target="_blank" class="text-xs text-primary hover:text-primary-dark">
|
||||
<i class="fas fa-external-link-alt mr-1"></i>
|
||||
Registry
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold {{ tld.is_active ? 'bg-green-100 dark:bg-green-500/10 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300' }}">
|
||||
{{ tld.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
{% if tld.rdap_servers %}
|
||||
{% set rdapServers = tld.rdap_servers|from_json %}
|
||||
{% if rdapServers is iterable and rdapServers is not empty %}
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-database text-gray-400 dark:text-slate-500 mr-2 w-4 mt-0.5"></i>
|
||||
<div class="flex-1">
|
||||
<div class="font-mono text-xs bg-gray-50 dark:bg-slate-700 px-2 py-1 rounded mb-1 text-gray-900 dark:text-white">{{ rdapServers[0] }}</div>
|
||||
{% if rdapServers|length > 1 %}
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">+{{ rdapServers|length - 1 }} more RDAP server(s)</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if tld.whois_server %}
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-server text-gray-400 dark:text-slate-500 mr-2 w-4"></i>
|
||||
<span class="font-mono text-xs bg-gray-50 dark:bg-slate-700 px-2 py-1 rounded text-gray-900 dark:text-white">{{ tld.whois_server }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock text-gray-400 dark:text-slate-500 mr-2 w-4"></i>
|
||||
<span class="text-gray-500 dark:text-slate-400">{{ tld.updated_at ? tld.updated_at|date('M d, H:i') : 'Never updated' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 mt-3">
|
||||
<a href="/tld-registry/{{ tld.id }}" class="{{ (session.role is defined and session.role == 'admin') ? 'flex-1' : 'w-full' }} px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
|
||||
<i class="fas fa-eye mr-1"></i> View
|
||||
</a>
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex-1 px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors" onclick="return confirm('Refresh TLD data?')">
|
||||
<i class="fas fa-sync-alt mr-1"></i> Refresh
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-globe text-gray-300 dark:text-slate-600 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-slate-300 mb-1">No TLDs Found</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">
|
||||
{% if currentFilters.search is not empty %}
|
||||
No TLDs match your search criteria.
|
||||
{% else %}
|
||||
Start by importing the TLD list from IANA.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if currentFilters.search is empty %}
|
||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||
{{ csrf_field() }}
|
||||
<input type="hidden" name="import_type" value="complete_workflow">
|
||||
<button type="submit" class="inline-flex items-center px-5 py-2.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-rocket mr-2"></i>
|
||||
Import TLDs
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Pagination Controls #}
|
||||
{% if pagination.total_pages > 1 %}
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Page <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.current_page }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total_pages }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{% set currentPage = pagination.current_page %}
|
||||
{% set totalPages = pagination.total_pages %}
|
||||
|
||||
{# First Page #}
|
||||
{% if currentPage > 1 %}
|
||||
<a href="{{ pagination_url(1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Previous Page #}
|
||||
{% if currentPage > 1 %}
|
||||
<a href="{{ pagination_url(currentPage - 1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Page Numbers #}
|
||||
{% set range = 2 %}
|
||||
{% set start = max(1, currentPage - range) %}
|
||||
{% set end = min(totalPages, currentPage + range) %}
|
||||
|
||||
{% if start > 1 %}
|
||||
<a href="{{ pagination_url(1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">1</a>
|
||||
{% if start > 2 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for i in start..end %}
|
||||
{% if i == currentPage %}
|
||||
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">{{ i }}</span>
|
||||
{% else %}
|
||||
<a href="{{ pagination_url(i, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ i }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if end < totalPages %}
|
||||
{% if end < totalPages - 1 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
<a href="{{ pagination_url(totalPages, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ totalPages }}</a>
|
||||
{% endif %}
|
||||
|
||||
{# Next Page #}
|
||||
{% if currentPage < totalPages %}
|
||||
<a href="{{ pagination_url(currentPage + 1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Last Page #}
|
||||
{% if currentPage < totalPages %}
|
||||
<a href="{{ pagination_url(totalPages, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Create TLD Modal #}
|
||||
<div id="createTldModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full">
|
||||
<form method="POST" action="/tld-registry/create">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Create New TLD</h3>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label for="create_tld" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">TLD Name</label>
|
||||
<input type="text" id="create_tld" name="tld" required
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., .com, .xyz, .co.uk">
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">The dot prefix will be added automatically. Multi-level TLDs supported (e.g., co.uk, com.au)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="create_whois_server" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">WHOIS Server (Optional)</label>
|
||||
<input type="text" id="create_whois_server" name="whois_server"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., whois.verisign-grs.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="create_rdap_servers" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">RDAP Servers (Optional)</label>
|
||||
<textarea id="create_rdap_servers" name="rdap_servers" rows="2"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., https://rdap.verisign.com/com/v1/"></textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">One URL per line or comma-separated</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="create_registry_url" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Registry URL (Optional)</label>
|
||||
<input type="url" id="create_registry_url" name="registry_url"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., https://www.verisign.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-slate-900 flex justify-end space-x-3 rounded-b-lg">
|
||||
<button type="button" onclick="closeCreateTldModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
|
||||
Create TLD
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Import TLD Modal #}
|
||||
<div id="tldImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>Import TLDs
|
||||
</h3>
|
||||
<button onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tld-registry/import" enctype="multipart/form-data" id="tldImportForm">
|
||||
{{ csrf_field() }}
|
||||
<div class="p-6 space-y-4">
|
||||
{# Drag & Drop Zone #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
|
||||
<div id="tldDropzone" class="relative border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50 dark:hover:bg-slate-700">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="tldFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="tldDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-slate-500 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 dark:text-slate-400 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON · Max {{ max_upload_size() }}</p>
|
||||
</div>
|
||||
<div id="tldDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tldFileName"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500" id="tldFileSize"></p>
|
||||
<button type="button" id="tldFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 dark:text-slate-300 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-800 px-1 rounded">tld, whois_server, rdap_servers, registry_url, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Existing TLDs will be updated. New TLDs will be created as active.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('tldImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="tldImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import TLDs
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleAllCheckboxes(selectAllCheckbox) {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = selectAllCheckbox.checked;
|
||||
});
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||
const count = checkboxes.length;
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
selectedCount.textContent = count + ' TLD(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
}
|
||||
|
||||
const allCheckboxes = document.querySelectorAll('.tld-checkbox');
|
||||
if (selectAllCheckbox) {
|
||||
if (count === 0) {
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
selectAllCheckbox.checked = false;
|
||||
} else if (count === allCheckboxes.length) {
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
selectAllCheckbox.checked = true;
|
||||
} else {
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = false;
|
||||
}
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('Please select TLDs to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Are you sure you want to delete ${checkboxes.length} selected TLD(s)? This action cannot be undone.`)) {
|
||||
const form = document.getElementById('bulk-delete-form');
|
||||
checkboxes.forEach(checkbox => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'tld_ids[]';
|
||||
input.value = checkbox.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', updateSelectedCount);
|
||||
});
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
function openCreateTldModal() {
|
||||
document.getElementById('createTldModal').classList.remove('hidden');
|
||||
document.getElementById('create_tld').focus();
|
||||
}
|
||||
|
||||
function closeCreateTldModal() {
|
||||
document.getElementById('createTldModal').classList.add('hidden');
|
||||
document.querySelector('#createTldModal form').reset();
|
||||
}
|
||||
|
||||
document.getElementById('createTldModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateTldModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('tldImportModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
document.getElementById('tldImportModal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
const exportWrapper = document.getElementById('tldExportDropdownWrapper');
|
||||
if (exportWrapper && !exportWrapper.contains(e.target)) {
|
||||
document.getElementById('tldExportDropdownMenu').classList.add('hidden');
|
||||
}
|
||||
const ianaWrapper = document.getElementById('ianaDropdownWrapper');
|
||||
if (ianaWrapper && !ianaWrapper.contains(e.target)) {
|
||||
document.getElementById('ianaDropdownMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateTldModal();
|
||||
document.getElementById('tldImportModal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
(function() {
|
||||
const dropzone = document.getElementById('tldDropzone');
|
||||
const fileInput = document.getElementById('tldFileInput');
|
||||
const content = document.getElementById('tldDropzoneContent');
|
||||
const fileInfo = document.getElementById('tldDropzoneFile');
|
||||
const fileName = document.getElementById('tldFileName');
|
||||
const fileSize = document.getElementById('tldFileSize');
|
||||
const removeBtn = document.getElementById('tldFileRemove');
|
||||
const form = document.getElementById('tldImportForm');
|
||||
const submitBtn = document.getElementById('tldImportBtn');
|
||||
|
||||
if (!dropzone) return;
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return bytes + ' B';
|
||||
}
|
||||
|
||||
function showFile(file) {
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatSize(file.size);
|
||||
content.classList.add('hidden');
|
||||
fileInfo.classList.remove('hidden');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
function resetDropzone() {
|
||||
fileInput.value = '';
|
||||
content.classList.remove('hidden');
|
||||
fileInfo.classList.add('hidden');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (this.files.length) showFile(this.files[0]);
|
||||
});
|
||||
|
||||
removeBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resetDropzone();
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.remove('border-gray-300');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(evt => {
|
||||
dropzone.addEventListener(evt, function(e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files.length) {
|
||||
dropzone.classList.remove('border-primary', 'bg-primary/5');
|
||||
dropzone.classList.add('border-gray-300');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1.5"></i>Importing...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,427 +0,0 @@
|
||||
<?php
|
||||
$title = 'TLD Details';
|
||||
$pageTitle = htmlspecialchars($tld['tld']);
|
||||
$pageDescription = 'TLD registry information and server details';
|
||||
$pageIcon = 'fas fa-globe';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<!-- Top Action Bar -->
|
||||
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-primary text-white">
|
||||
<i class="fas fa-globe mr-1.5"></i>
|
||||
TLD Registry
|
||||
</span>
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold <?= $tld['is_active'] ? 'bg-green-100 text-green-700 border border-green-200' : 'bg-gray-100 text-gray-700 border border-gray-200' ?>">
|
||||
<i class="fas <?= $tld['is_active'] ? 'fa-check-circle' : 'fa-pause-circle' ?> mr-1.5"></i>
|
||||
<?= $tld['is_active'] ? 'Active' : 'Inactive' ?>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Refresh TLD data from IANA?')">
|
||||
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||
Refresh
|
||||
</a>
|
||||
<a href="/tld-registry/<?= $tld['id'] ?>/toggle-active" class="inline-flex items-center justify-center px-3 py-2 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Toggle TLD status?')">
|
||||
<i class="fas fa-power-off mr-1.5"></i>
|
||||
Toggle
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<a href="/tld-registry" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-gray-700 text-xs rounded-lg hover:bg-gray-50 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-arrow-left mr-1.5"></i>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main 2-Column Layout -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
|
||||
<!-- LEFT COLUMN -->
|
||||
<div class="space-y-3">
|
||||
|
||||
<!-- TLD Information -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-info-circle text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
TLD Information
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
|
||||
<div>
|
||||
<label class="text-gray-500 font-medium block mb-0.5">TLD</label>
|
||||
<p class="text-gray-900 font-semibold"><?= htmlspecialchars($tld['tld']) ?></p>
|
||||
</div>
|
||||
<?php if ($tld['registry_url']): ?>
|
||||
<div>
|
||||
<label class="text-gray-500 font-medium block mb-0.5">Registry URL</label>
|
||||
<a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="text-blue-600 hover:text-blue-800 flex items-center">
|
||||
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||
Visit Registry
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($tld['registration_date']): ?>
|
||||
<div>
|
||||
<label class="text-gray-500 font-medium block mb-0.5">Registration Date</label>
|
||||
<p class="text-gray-900"><?= date('M j, Y', strtotime($tld['registration_date'])) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($tld['record_last_updated']): ?>
|
||||
<div>
|
||||
<label class="text-gray-500 font-medium block mb-0.5">Record Last Updated</label>
|
||||
<p class="text-gray-900"><?= date('M j, Y', strtotime($tld['record_last_updated'])) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RDAP Servers -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-database text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
RDAP Servers
|
||||
<?php if ($tld['rdap_servers']): ?>
|
||||
<?php
|
||||
$rdapServers = json_decode($tld['rdap_servers'], true);
|
||||
if (is_array($rdapServers) && !empty($rdapServers)):
|
||||
?>
|
||||
(<?= count($rdapServers) ?>)
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</h3>
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<button onclick="openEditRdapModal()" class="inline-flex items-center px-2 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-edit mr-1" style="font-size: 9px;"></i>
|
||||
Edit
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<?php if ($tld['rdap_servers']): ?>
|
||||
<?php
|
||||
$rdapServers = json_decode($tld['rdap_servers'], true);
|
||||
if (is_array($rdapServers) && !empty($rdapServers)):
|
||||
?>
|
||||
<div class="space-y-1.5">
|
||||
<?php foreach ($rdapServers as $index => $server): ?>
|
||||
<div class="flex items-center p-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors">
|
||||
<div class="w-6 h-6 bg-indigo-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
|
||||
<?= $index + 1 ?>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-800"><?= htmlspecialchars($server) ?></p>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="text-xs text-gray-400 italic">No RDAP servers configured</p>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<p class="text-xs text-gray-400 italic">No RDAP servers configured</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WHOIS Server -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-server text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
WHOIS Server
|
||||
</h3>
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<button onclick="openEditWhoisModal()" class="inline-flex items-center px-2 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-edit mr-1" style="font-size: 9px;"></i>
|
||||
Edit
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<?php if ($tld['whois_server']): ?>
|
||||
<div class="flex items-center p-2 bg-gray-50 rounded">
|
||||
<div class="w-6 h-6 bg-orange-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
|
||||
<i class="fas fa-server"></i>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-800"><?= htmlspecialchars($tld['whois_server']) ?></p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="text-xs text-gray-400 italic">No WHOIS server configured</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN -->
|
||||
<div class="space-y-3">
|
||||
|
||||
<!-- Import History -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-history text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Import History
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center p-2 bg-blue-50 rounded border border-blue-200">
|
||||
<div class="w-7 h-7 bg-blue-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-plus text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 font-medium">Created</p>
|
||||
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y H:i', strtotime($tld['created_at'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($tld['updated_at']): ?>
|
||||
<div class="flex items-center p-2 bg-green-50 rounded border border-green-200">
|
||||
<div class="w-7 h-7 bg-green-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-sync text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 font-medium">Last Updated</p>
|
||||
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y H:i', strtotime($tld['updated_at'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($tld['iana_publication_date']): ?>
|
||||
<div class="flex items-center p-2 bg-indigo-50 rounded border border-indigo-200">
|
||||
<div class="w-7 h-7 bg-indigo-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-calendar text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 font-medium">IANA Publication</p>
|
||||
<p class="text-xs font-semibold text-gray-900"><?= date('M j, Y H:i', strtotime($tld['iana_publication_date'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-bolt text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Quick Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="flex items-center p-3 border border-gray-200 hover:border-green-500 hover:bg-green-50 rounded-lg transition-all duration-200 group" onclick="return confirm('Refresh TLD data from IANA?')">
|
||||
<div class="w-9 h-9 bg-green-50 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 transition-colors duration-200">
|
||||
<i class="fas fa-sync-alt text-sm"></i>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-green-700">Refresh from IANA</span>
|
||||
</a>
|
||||
<a href="/tld-registry/<?= $tld['id'] ?>/toggle-active" class="flex items-center p-3 border border-gray-200 hover:border-orange-500 hover:bg-orange-50 rounded-lg transition-all duration-200 group" onclick="return confirm('Toggle TLD status?')">
|
||||
<div class="w-9 h-9 bg-orange-50 group-hover:bg-orange-500 rounded-lg flex items-center justify-center group-hover:text-white text-orange-600 transition-colors duration-200">
|
||||
<i class="fas fa-power-off text-sm"></i>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-orange-700">Toggle Status</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($tld['registry_url']): ?>
|
||||
<a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="flex items-center p-3 border border-gray-200 hover:border-blue-500 hover:bg-blue-50 rounded-lg transition-all duration-200 group">
|
||||
<div class="w-9 h-9 bg-blue-50 group-hover:bg-blue-500 rounded-lg flex items-center justify-center group-hover:text-white text-blue-600 transition-colors duration-200">
|
||||
<i class="fas fa-external-link-alt text-sm"></i>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-blue-700">Visit Registry</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw Data (Collapsible) -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button onclick="toggleRawData()" class="w-full px-4 py-2 border-b border-gray-200 bg-gray-50 text-left hover:bg-gray-100 transition-colors">
|
||||
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-code text-gray-400 mr-2" style="font-size: 10px;"></i>
|
||||
Raw TLD Data
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="raw-data-chevron"></i>
|
||||
</h3>
|
||||
</button>
|
||||
<div id="raw-data" class="hidden p-4 bg-gray-900 max-h-64 overflow-y-auto">
|
||||
<pre class="text-xs text-green-400 font-mono"><?= htmlspecialchars(json_encode([
|
||||
'tld' => $tld['tld'],
|
||||
'rdap_servers' => $tld['rdap_servers'] ? json_decode($tld['rdap_servers'], true) : null,
|
||||
'whois_server' => $tld['whois_server'],
|
||||
'registry_url' => $tld['registry_url'],
|
||||
'registration_date' => $tld['registration_date'],
|
||||
'record_last_updated' => $tld['record_last_updated'],
|
||||
'iana_publication_date' => $tld['iana_publication_date'],
|
||||
'is_active' => $tld['is_active'],
|
||||
'created_at' => $tld['created_at'],
|
||||
'updated_at' => $tld['updated_at']
|
||||
], JSON_PRETTY_PRINT)) ?></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Edit WHOIS Server Modal -->
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<div id="editWhoisModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-server text-orange-600 mr-2"></i>
|
||||
Edit WHOIS Server
|
||||
</h3>
|
||||
<button onclick="closeEditWhoisModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tld-registry/<?= $tld['id'] ?>/update-whois-server" class="p-6">
|
||||
<?= csrf_field() ?>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
WHOIS Server
|
||||
</label>
|
||||
<input type="text"
|
||||
name="whois_server"
|
||||
id="whois_server_input"
|
||||
value="<?= htmlspecialchars($tld['whois_server'] ?? '') ?>"
|
||||
placeholder="whois.example.com"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
Enter the WHOIS server hostname (e.g., whois.example.com). Leave empty to remove.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Save Changes
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="closeEditWhoisModal()"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit RDAP Servers Modal -->
|
||||
<div id="editRdapModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-database text-indigo-600 mr-2"></i>
|
||||
Edit RDAP Servers
|
||||
</h3>
|
||||
<button onclick="closeEditRdapModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tld-registry/<?= $tld['id'] ?>/update-rdap-servers" class="p-6">
|
||||
<?= csrf_field() ?>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
RDAP Servers
|
||||
</label>
|
||||
<textarea name="rdap_servers"
|
||||
id="rdap_servers_input"
|
||||
rows="6"
|
||||
placeholder="https://rdap.example.com/ https://rdap2.example.com/"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm font-mono"><?php
|
||||
if ($tld['rdap_servers']):
|
||||
$rdapServers = json_decode($tld['rdap_servers'], true);
|
||||
if (is_array($rdapServers) && !empty($rdapServers)):
|
||||
echo htmlspecialchars(implode("\n", $rdapServers));
|
||||
endif;
|
||||
endif;
|
||||
?></textarea>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
Enter RDAP server URLs (one per line or comma-separated). Must start with http:// or https://. Leave empty to remove all servers.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Save Changes
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="closeEditRdapModal()"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
function toggleRawData() {
|
||||
const dataDiv = document.getElementById('raw-data');
|
||||
const chevron = document.getElementById('raw-data-chevron');
|
||||
dataDiv.classList.toggle('hidden');
|
||||
chevron.classList.toggle('rotate-180');
|
||||
}
|
||||
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
function openEditWhoisModal() {
|
||||
document.getElementById('editWhoisModal').classList.remove('hidden');
|
||||
document.getElementById('whois_server_input').focus();
|
||||
}
|
||||
|
||||
function closeEditWhoisModal() {
|
||||
document.getElementById('editWhoisModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function openEditRdapModal() {
|
||||
document.getElementById('editRdapModal').classList.remove('hidden');
|
||||
document.getElementById('rdap_servers_input').focus();
|
||||
}
|
||||
|
||||
function closeEditRdapModal() {
|
||||
document.getElementById('editRdapModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close modals on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeEditWhoisModal();
|
||||
closeEditRdapModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modals when clicking outside
|
||||
document.getElementById('editWhoisModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeEditWhoisModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('editRdapModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeEditRdapModal();
|
||||
}
|
||||
});
|
||||
<?php endif; ?>
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
420
app/Views/tld-registry/view.twig
Normal file
420
app/Views/tld-registry/view.twig
Normal file
@@ -0,0 +1,420 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'TLD Details' %}
|
||||
{% set pageTitle = tld.tld %}
|
||||
{% set pageDescription = 'TLD registry information and server details' %}
|
||||
{% set pageIcon = 'fas fa-globe' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# Top Action Bar #}
|
||||
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-primary text-white">
|
||||
<i class="fas fa-globe mr-1.5"></i>
|
||||
TLD Registry
|
||||
</span>
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold {{ tld.is_active ? 'bg-green-100 dark:bg-green-500/10 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-500/30' : 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 border border-gray-200 dark:border-slate-600' }}">
|
||||
<i class="fas {{ tld.is_active ? 'fa-check-circle' : 'fa-pause-circle' }} mr-1.5"></i>
|
||||
{{ tld.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Refresh TLD data from IANA?')">
|
||||
<i class="fas fa-sync-alt mr-1.5"></i>
|
||||
Refresh
|
||||
</a>
|
||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="inline-flex items-center justify-center px-3 py-2 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Toggle TLD status?')">
|
||||
<i class="fas fa-power-off mr-1.5"></i>
|
||||
Toggle
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/tld-registry" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium min-w-[80px] h-[32px]">
|
||||
<i class="fas fa-arrow-left mr-1.5"></i>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Main 2-Column Layout #}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
|
||||
{# LEFT COLUMN #}
|
||||
<div class="space-y-3">
|
||||
|
||||
{# TLD Information #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-info-circle text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
TLD Information
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">TLD</label>
|
||||
<p class="text-gray-900 dark:text-white font-semibold">{{ tld.tld }}</p>
|
||||
</div>
|
||||
{% if tld.registry_url %}
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registry URL</label>
|
||||
<a href="{{ tld.registry_url }}" target="_blank" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center">
|
||||
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
|
||||
Visit Registry
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tld.registration_date %}
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registration Date</label>
|
||||
<p class="text-gray-900 dark:text-white">{{ tld.registration_date|date('M j, Y') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if tld.record_last_updated %}
|
||||
<div>
|
||||
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Record Last Updated</label>
|
||||
<p class="text-gray-900 dark:text-white">{{ tld.record_last_updated|date('M j, Y') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# RDAP Servers #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex items-center justify-between">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-database text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
RDAP Servers
|
||||
{% if tld.rdap_servers %}
|
||||
{% set rdapServers = tld.rdap_servers|from_json %}
|
||||
{% if rdapServers is iterable and rdapServers is not empty %}
|
||||
({{ rdapServers|length }})
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<button onclick="openEditRdapModal()" class="inline-flex items-center px-2 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-edit mr-1" style="font-size: 9px;"></i>
|
||||
Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{% if tld.rdap_servers %}
|
||||
{% set rdapServers = tld.rdap_servers|from_json %}
|
||||
{% if rdapServers is iterable and rdapServers is not empty %}
|
||||
<div class="space-y-1.5">
|
||||
{% for server in rdapServers %}
|
||||
<div class="flex items-center p-2 bg-gray-50 dark:bg-slate-700 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||
<div class="w-6 h-6 bg-indigo-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
|
||||
{{ loop.index }}
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-800 dark:text-slate-200">{{ server }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 italic">No RDAP servers configured</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 italic">No RDAP servers configured</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# WHOIS Server #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex items-center justify-between">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-server text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
WHOIS Server
|
||||
</h3>
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<button onclick="openEditWhoisModal()" class="inline-flex items-center px-2 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-edit mr-1" style="font-size: 9px;"></i>
|
||||
Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{% if tld.whois_server %}
|
||||
<div class="flex items-center p-2 bg-gray-50 dark:bg-slate-700 rounded">
|
||||
<div class="w-6 h-6 bg-orange-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
|
||||
<i class="fas fa-server"></i>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-800 dark:text-slate-200">{{ tld.whois_server }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-xs text-gray-400 dark:text-slate-500 italic">No WHOIS server configured</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# RIGHT COLUMN #}
|
||||
<div class="space-y-3">
|
||||
|
||||
{# Import History #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-history text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Import History
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center p-2 bg-blue-50 dark:bg-blue-500/10 rounded border border-blue-200 dark:border-blue-500/30">
|
||||
<div class="w-7 h-7 bg-blue-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-plus text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Created</p>
|
||||
<p class="text-xs font-semibold text-gray-900 dark:text-white">{{ tld.created_at|date('M j, Y H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if tld.updated_at %}
|
||||
<div class="flex items-center p-2 bg-green-50 dark:bg-green-500/10 rounded border border-green-200 dark:border-green-500/30">
|
||||
<div class="w-7 h-7 bg-green-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-sync text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Last Updated</p>
|
||||
<p class="text-xs font-semibold text-gray-900 dark:text-white">{{ tld.updated_at|date('M j, Y H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if tld.iana_publication_date %}
|
||||
<div class="flex items-center p-2 bg-indigo-50 dark:bg-indigo-500/10 rounded border border-indigo-200 dark:border-indigo-500/30">
|
||||
<div class="w-7 h-7 bg-indigo-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-calendar text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">IANA Publication</p>
|
||||
<p class="text-xs font-semibold text-gray-900 dark:text-white">{{ tld.iana_publication_date|date('M j, Y H:i') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Quick Actions #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
|
||||
<i class="fas fa-bolt text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Quick Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4 space-y-2">
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<a href="/tld-registry/{{ tld.id }}/refresh" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-500/10 rounded-lg transition-all duration-200 group" onclick="return confirm('Refresh TLD data from IANA?')">
|
||||
<div class="w-9 h-9 bg-green-50 dark:bg-green-500/10 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 dark:text-green-400 transition-colors duration-200">
|
||||
<i class="fas fa-sync-alt text-sm"></i>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-slate-300 group-hover:text-green-700 dark:group-hover:text-green-400">Refresh from IANA</span>
|
||||
</a>
|
||||
<a href="/tld-registry/{{ tld.id }}/toggle-active" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-orange-500 hover:bg-orange-50 dark:hover:bg-orange-500/10 rounded-lg transition-all duration-200 group" onclick="return confirm('Toggle TLD status?')">
|
||||
<div class="w-9 h-9 bg-orange-50 dark:bg-orange-500/10 group-hover:bg-orange-500 rounded-lg flex items-center justify-center group-hover:text-white text-orange-600 dark:text-orange-400 transition-colors duration-200">
|
||||
<i class="fas fa-power-off text-sm"></i>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-slate-300 group-hover:text-orange-700 dark:group-hover:text-orange-400">Toggle Status</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if tld.registry_url %}
|
||||
<a href="{{ tld.registry_url }}" target="_blank" class="flex items-center p-3 border border-gray-200 dark:border-slate-700 hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-500/10 rounded-lg transition-all duration-200 group">
|
||||
<div class="w-9 h-9 bg-blue-50 dark:bg-blue-500/10 group-hover:bg-blue-500 rounded-lg flex items-center justify-center group-hover:text-white text-blue-600 dark:text-blue-400 transition-colors duration-200">
|
||||
<i class="fas fa-external-link-alt text-sm"></i>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-slate-300 group-hover:text-blue-700 dark:group-hover:text-blue-400">Visit Registry</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Raw Data (Collapsible) #}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<button onclick="toggleRawData()" class="w-full px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 text-left hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors">
|
||||
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-code text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
|
||||
Raw TLD Data
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 dark:text-slate-500 text-xs transition-transform" id="raw-data-chevron"></i>
|
||||
</h3>
|
||||
</button>
|
||||
<div id="raw-data" class="hidden p-4 bg-gray-900 max-h-64 overflow-y-auto">
|
||||
{% set rawData = {
|
||||
tld: tld.tld,
|
||||
rdap_servers: tld.rdap_servers ? tld.rdap_servers|from_json : null,
|
||||
whois_server: tld.whois_server,
|
||||
registry_url: tld.registry_url,
|
||||
registration_date: tld.registration_date,
|
||||
record_last_updated: tld.record_last_updated,
|
||||
iana_publication_date: tld.iana_publication_date,
|
||||
is_active: tld.is_active,
|
||||
created_at: tld.created_at,
|
||||
updated_at: tld.updated_at
|
||||
} %}
|
||||
<pre class="text-xs text-green-400 font-mono">{{ rawData|json_encode(constant('JSON_PRETTY_PRINT'))|e }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# Edit WHOIS Server Modal #}
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
<div id="editWhoisModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-server text-orange-600 mr-2"></i>
|
||||
Edit WHOIS Server
|
||||
</h3>
|
||||
<button onclick="closeEditWhoisModal()" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300 transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tld-registry/{{ tld.id }}/update-whois-server" class="p-6">
|
||||
{{ csrf_field() }}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
WHOIS Server
|
||||
</label>
|
||||
<input type="text"
|
||||
name="whois_server"
|
||||
id="whois_server_input"
|
||||
value="{{ tld.whois_server|default('') }}"
|
||||
placeholder="whois.example.com"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Enter the WHOIS server hostname (e.g., whois.example.com). Leave empty to remove.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Save Changes
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="closeEditWhoisModal()"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Edit RDAP Servers Modal #}
|
||||
<div id="editRdapModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-database text-indigo-600 mr-2"></i>
|
||||
Edit RDAP Servers
|
||||
</h3>
|
||||
<button onclick="closeEditRdapModal()" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300 transition-colors">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tld-registry/{{ tld.id }}/update-rdap-servers" class="p-6">
|
||||
{{ csrf_field() }}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
RDAP Servers
|
||||
</label>
|
||||
{% set rdapTextarea = '' %}
|
||||
{% if tld.rdap_servers %}
|
||||
{% set rdapServers = tld.rdap_servers|from_json %}
|
||||
{% if rdapServers is iterable and rdapServers is not empty %}
|
||||
{% set rdapTextarea = rdapServers|join("\n") %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<textarea name="rdap_servers"
|
||||
id="rdap_servers_input"
|
||||
rows="6"
|
||||
placeholder="https://rdap.example.com/ https://rdap2.example.com/"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm font-mono bg-white dark:bg-slate-900 text-gray-900 dark:text-white">{{ rdapTextarea }}</textarea>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Enter RDAP server URLs (one per line or comma-separated). Must start with http:// or https://. Leave empty to remove all servers.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Save Changes
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="closeEditRdapModal()"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleRawData() {
|
||||
const dataDiv = document.getElementById('raw-data');
|
||||
const chevron = document.getElementById('raw-data-chevron');
|
||||
dataDiv.classList.toggle('hidden');
|
||||
chevron.classList.toggle('rotate-180');
|
||||
}
|
||||
|
||||
{% if session.role is defined and session.role == 'admin' %}
|
||||
function openEditWhoisModal() {
|
||||
document.getElementById('editWhoisModal').classList.remove('hidden');
|
||||
document.getElementById('whois_server_input').focus();
|
||||
}
|
||||
|
||||
function closeEditWhoisModal() {
|
||||
document.getElementById('editWhoisModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function openEditRdapModal() {
|
||||
document.getElementById('editRdapModal').classList.remove('hidden');
|
||||
document.getElementById('rdap_servers_input').focus();
|
||||
}
|
||||
|
||||
function closeEditRdapModal() {
|
||||
document.getElementById('editRdapModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeEditWhoisModal();
|
||||
closeEditRdapModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('editWhoisModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeEditWhoisModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('editRdapModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeEditRdapModal();
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,61 +1,61 @@
|
||||
<?php
|
||||
$title = 'Create User';
|
||||
$pageTitle = 'Create User';
|
||||
$pageDescription = 'Add a new user to the system';
|
||||
$pageIcon = 'fas fa-user-plus';
|
||||
ob_start();
|
||||
?>
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Create User' %}
|
||||
{% set pageTitle = 'Create User' %}
|
||||
{% set pageDescription = 'Add a new user to the system' %}
|
||||
{% set pageIcon = 'fas fa-user-plus' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-user text-gray-400 mr-2 text-sm"></i>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
|
||||
User Information
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/users/store" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
{{ csrf_field() }}
|
||||
|
||||
<!-- Name & Username Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<!-- Full Name -->
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Full Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
<input type="text"
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
required
|
||||
autofocus
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="John Doe">
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
The user's display name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Username -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Username <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 dark:text-slate-500">
|
||||
<i class="fas fa-at text-sm"></i>
|
||||
</span>
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="johndoe">
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Letters, numbers, and underscores only
|
||||
</p>
|
||||
</div>
|
||||
@@ -65,103 +65,103 @@ ob_start();
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Email Address <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 dark:text-slate-500">
|
||||
<i class="fas fa-envelope text-sm"></i>
|
||||
</span>
|
||||
<input type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
<input type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="john@example.com">
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Used for login and notifications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="role" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Role <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 dark:text-slate-500">
|
||||
<i class="fas fa-shield-alt text-sm"></i>
|
||||
</span>
|
||||
<select id="role"
|
||||
name="role"
|
||||
<select id="role"
|
||||
name="role"
|
||||
required
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm appearance-none bg-white">
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm appearance-none bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<span class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-gray-400">
|
||||
<span class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-gray-400 dark:text-slate-500">
|
||||
<i class="fas fa-chevron-down text-xs"></i>
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Admins have full system access
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Section -->
|
||||
<div class="border-t border-gray-200 pt-5 mt-5">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<i class="fas fa-lock text-gray-400 mr-2"></i>
|
||||
<div class="border-t border-gray-200 dark:border-slate-700 pt-5 mt-5">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-slate-500 mr-2"></i>
|
||||
Password
|
||||
</h3>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
<button type="button"
|
||||
<button type="button"
|
||||
onclick="togglePassword('password')"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600">
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">
|
||||
<i class="fas fa-eye text-sm" id="password-toggle-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Minimum 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Confirm Password <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="password"
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
required
|
||||
<input type="password"
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
<button type="button"
|
||||
<button type="button"
|
||||
onclick="togglePassword('password_confirm')"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600">
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">
|
||||
<i class="fas fa-eye text-sm" id="password_confirm-toggle-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Re-enter the password to confirm
|
||||
</p>
|
||||
</div>
|
||||
@@ -170,13 +170,13 @@ ob_start();
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||
<button type="submit"
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Create User
|
||||
</button>
|
||||
<a href="/users"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<a href="/users"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
@@ -186,7 +186,7 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<!-- Info Section -->
|
||||
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
@@ -194,8 +194,8 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">What happens next?</h3>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">What happens next?</h3>
|
||||
<ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
|
||||
<li class="flex items-center">
|
||||
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
|
||||
<span class="ml-2">Admin-created users are automatically verified and can log in immediately</span>
|
||||
@@ -218,7 +218,7 @@ ob_start();
|
||||
function togglePassword(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
const icon = document.getElementById(fieldId + '-toggle-icon');
|
||||
|
||||
|
||||
if (field.type === 'password') {
|
||||
field.type = 'text';
|
||||
icon.classList.remove('fa-eye');
|
||||
@@ -230,11 +230,10 @@ function togglePassword(fieldId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Password confirmation validation
|
||||
document.getElementById('password_confirm').addEventListener('input', function() {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirm = this.value;
|
||||
|
||||
|
||||
if (confirm && password !== confirm) {
|
||||
this.setCustomValidity('Passwords do not match');
|
||||
this.classList.add('border-red-300');
|
||||
@@ -246,8 +245,4 @@ document.getElementById('password_confirm').addEventListener('input', function()
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
@@ -1,61 +1,61 @@
|
||||
<?php
|
||||
$title = 'Edit User';
|
||||
$pageTitle = 'Edit User';
|
||||
$pageDescription = 'Update user information and permissions';
|
||||
$pageIcon = 'fas fa-user-edit';
|
||||
ob_start();
|
||||
?>
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'Edit User' %}
|
||||
{% set pageTitle = 'Edit User' %}
|
||||
{% set pageDescription = 'Update user information and permissions' %}
|
||||
{% set pageIcon = 'fas fa-user-edit' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-user-edit text-gray-400 mr-2 text-sm"></i>
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-user-edit text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
|
||||
User Information
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/users/<?= $user['id'] ?>/update" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= $user['id'] ?>">
|
||||
<form method="POST" action="/users/{{ user.id }}/update" class="space-y-5">
|
||||
{{ csrf_field() }}
|
||||
<input type="hidden" name="id" value="{{ user.id }}">
|
||||
|
||||
<!-- Name & Username Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<!-- Full Name -->
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Full Name <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
<input type="text"
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
required
|
||||
autofocus
|
||||
value="<?= htmlspecialchars($user['full_name'] ?? '') ?>"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
value="{{ user.full_name|default('') }}"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="John Doe">
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
The user's display name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Username (Read-only) -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Username
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 dark:text-slate-500">
|
||||
<i class="fas fa-at text-sm"></i>
|
||||
</span>
|
||||
<input type="text"
|
||||
id="username"
|
||||
value="<?= htmlspecialchars($user['username']) ?>"
|
||||
<input type="text"
|
||||
id="username"
|
||||
value="{{ user.username }}"
|
||||
readonly
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed text-sm">
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-50 dark:bg-slate-900 text-gray-500 dark:text-slate-400 cursor-not-allowed text-sm">
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Username cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
@@ -65,115 +65,115 @@ ob_start();
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Email Address <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 dark:text-slate-500">
|
||||
<i class="fas fa-envelope text-sm"></i>
|
||||
</span>
|
||||
<input type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
<input type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
value="<?= htmlspecialchars($user['email']) ?>"
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
value="{{ user.email }}"
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="john@example.com">
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Used for login and notifications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="role" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Role <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400">
|
||||
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-gray-400 dark:text-slate-500">
|
||||
<i class="fas fa-shield-alt text-sm"></i>
|
||||
</span>
|
||||
<select id="role"
|
||||
name="role"
|
||||
<select id="role"
|
||||
name="role"
|
||||
required
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm appearance-none bg-white">
|
||||
<option value="user" <?= $user['role'] === 'user' ? 'selected' : '' ?>>User</option>
|
||||
<option value="admin" <?= $user['role'] === 'admin' ? 'selected' : '' ?>>Admin</option>
|
||||
class="w-full pl-9 pr-3 py-2.5 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm appearance-none bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="user" {{ user.role == 'user' ? 'selected' : '' }}>User</option>
|
||||
<option value="admin" {{ user.role == 'admin' ? 'selected' : '' }}>Admin</option>
|
||||
</select>
|
||||
<span class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-gray-400">
|
||||
<span class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-gray-400 dark:text-slate-500">
|
||||
<i class="fas fa-chevron-down text-xs"></i>
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Admins have full system access
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center gap-3 bg-gray-50 border border-gray-200 rounded-lg px-4 py-3">
|
||||
<div class="flex items-center gap-3 bg-gray-50 dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg px-4 py-3">
|
||||
<input type="checkbox" id="is_active" name="is_active" value="1"
|
||||
<?= $user['is_active'] ? 'checked' : '' ?>
|
||||
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
|
||||
{{ user.is_active ? 'checked' : '' }}
|
||||
class="w-4 h-4 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary">
|
||||
<div>
|
||||
<label for="is_active" class="text-sm font-medium text-gray-700">
|
||||
<label for="is_active" class="text-sm font-medium text-gray-700 dark:text-slate-300">
|
||||
Active Account
|
||||
</label>
|
||||
<p class="text-xs text-gray-500">Inactive users cannot log in to the system</p>
|
||||
<p class="text-xs text-gray-500 dark:text-slate-400">Inactive users cannot log in to the system</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Section -->
|
||||
<div class="border-t border-gray-200 pt-5 mt-5">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center">
|
||||
<i class="fas fa-lock text-gray-400 mr-2"></i>
|
||||
<div class="border-t border-gray-200 dark:border-slate-700 pt-5 mt-5">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<i class="fas fa-lock text-gray-400 dark:text-slate-500 mr-2"></i>
|
||||
Change Password
|
||||
</h3>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<!-- New Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
New Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
minlength="8"
|
||||
class="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
<button type="button"
|
||||
<button type="button"
|
||||
onclick="togglePassword('password')"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600">
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">
|
||||
<i class="fas fa-eye text-sm" id="password-toggle-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Leave blank to keep current password
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div>
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
<label for="password_confirm" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="password"
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
<input type="password"
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
minlength="8"
|
||||
class="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
|
||||
class="w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
||||
placeholder="••••••••">
|
||||
<button type="button"
|
||||
<button type="button"
|
||||
onclick="togglePassword('password_confirm')"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600">
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">
|
||||
<i class="fas fa-eye text-sm" id="password_confirm-toggle-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
Re-enter the new password to confirm
|
||||
</p>
|
||||
</div>
|
||||
@@ -182,13 +182,13 @@ ob_start();
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||
<button type="submit"
|
||||
<button type="submit"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg font-medium transition-colors text-sm">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Update User
|
||||
</button>
|
||||
<a href="/users"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<a href="/users"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg font-medium hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
@@ -198,7 +198,7 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<!-- Account Info Section -->
|
||||
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/20 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
@@ -206,26 +206,26 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">Account Details</h3>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">Account Details</h3>
|
||||
<ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
|
||||
<li class="flex items-center">
|
||||
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
|
||||
<span class="ml-2">Email Verified:
|
||||
<span class="font-semibold <?= $user['email_verified'] ? 'text-green-600' : 'text-red-600' ?>">
|
||||
<?= $user['email_verified'] ? 'Yes' : 'No' ?>
|
||||
<span class="ml-2">Email Verified:
|
||||
<span class="font-semibold {{ user.email_verified ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' }}">
|
||||
{{ user.email_verified ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
|
||||
<span class="ml-2">Member Since:
|
||||
<span class="font-semibold text-gray-900"><?= date('M d, Y', strtotime($user['created_at'])) ?></span>
|
||||
<span class="ml-2">Member Since:
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ user.created_at|date('M d, Y') }}</span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
|
||||
<span class="ml-2">Last Login:
|
||||
<span class="font-semibold text-gray-900"><?= $user['last_login'] ? date('M d, Y H:i', strtotime($user['last_login'])) : 'Never' ?></span>
|
||||
<span class="ml-2">Last Login:
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ user.last_login ? user.last_login|date('M d, Y H:i') : 'Never' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -238,7 +238,7 @@ ob_start();
|
||||
function togglePassword(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
const icon = document.getElementById(fieldId + '-toggle-icon');
|
||||
|
||||
|
||||
if (field.type === 'password') {
|
||||
field.type = 'text';
|
||||
icon.classList.remove('fa-eye');
|
||||
@@ -250,11 +250,10 @@ function togglePassword(fieldId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Password confirmation validation
|
||||
document.getElementById('password_confirm').addEventListener('input', function() {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirm = this.value;
|
||||
|
||||
|
||||
if (confirm && password !== confirm) {
|
||||
this.setCustomValidity('Passwords do not match');
|
||||
this.classList.add('border-red-300');
|
||||
@@ -266,8 +265,4 @@ document.getElementById('password_confirm').addEventListener('input', function()
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
{% endblock %}
|
||||
@@ -1,559 +0,0 @@
|
||||
<?php
|
||||
$title = 'User Management';
|
||||
$pageTitle = 'User Management';
|
||||
$pageDescription = 'Manage system users and permissions';
|
||||
$pageIcon = 'fas fa-users';
|
||||
ob_start();
|
||||
|
||||
// Helper function to generate sort URL
|
||||
function sortUrl($column, $currentSort, $currentOrder) {
|
||||
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
|
||||
$params = $_GET;
|
||||
$params['sort'] = $column;
|
||||
$params['order'] = $newOrder;
|
||||
return '/users?' . http_build_query($params);
|
||||
}
|
||||
|
||||
// Helper function for sort icon
|
||||
function sortIcon($column, $currentSort, $currentOrder) {
|
||||
if ($currentSort !== $column) {
|
||||
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
|
||||
}
|
||||
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
|
||||
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
|
||||
}
|
||||
|
||||
// Get current filters
|
||||
$currentFilters = $filters ?? ['search' => '', 'role' => '', 'status' => '', 'sort' => 'username', 'order' => 'asc'];
|
||||
|
||||
// Mock pagination for now (will need to be implemented in controller)
|
||||
$pagination = $pagination ?? [
|
||||
'current_page' => 1,
|
||||
'total_pages' => 1,
|
||||
'per_page' => 25,
|
||||
'total' => count($users),
|
||||
'showing_from' => 1,
|
||||
'showing_to' => count($users)
|
||||
];
|
||||
?>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex justify-end">
|
||||
<a href="/users/create" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Add User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<form method="GET" action="/users" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search</label>
|
||||
<div class="relative">
|
||||
<input type="text" name="search" value="<?= htmlspecialchars($currentFilters['search']) ?>" placeholder="Search users..." class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Role</label>
|
||||
<select name="role" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin" <?= $currentFilters['role'] === 'admin' ? 'selected' : '' ?>>Admin</option>
|
||||
<option value="user" <?= $currentFilters['role'] === 'user' ? 'selected' : '' ?>>User</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
|
||||
<select name="status" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active" <?= $currentFilters['status'] === 'active' ? 'selected' : '' ?>>Active</option>
|
||||
<option value="inactive" <?= $currentFilters['status'] === 'inactive' ? 'selected' : '' ?>>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Apply/Reset Buttons -->
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="/users" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?></span> user(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/users" class="flex items-center gap-2">
|
||||
<!-- Preserve current filters -->
|
||||
<input type="hidden" name="search" value="<?= htmlspecialchars($currentFilters['search']) ?>">
|
||||
<input type="hidden" name="role" value="<?= htmlspecialchars($currentFilters['role']) ?>">
|
||||
<input type="hidden" name="status" value="<?= htmlspecialchars($currentFilters['status']) ?>">
|
||||
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="10" <?= $pagination['per_page'] == 10 ? 'selected' : '' ?>>10</option>
|
||||
<option value="25" <?= $pagination['per_page'] == 25 ? 'selected' : '' ?>>25</option>
|
||||
<option value="50" <?= $pagination['per_page'] == 50 ? 'selected' : '' ?>>50</option>
|
||||
<option value="100" <?= $pagination['per_page'] == 100 ? 'selected' : '' ?>>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when users are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="bulkToggleStatus('active')" class="inline-flex items-center px-4 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-user-check mr-1"></i> Activate Selected
|
||||
</button>
|
||||
<button type="button" onclick="bulkToggleStatus('inactive')" class="inline-flex items-center px-4 py-1.5 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-user-slash mr-1"></i> Deactivate Selected
|
||||
</button>
|
||||
<button type="button" onclick="bulkDeleteUsers()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-blue-100 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<?php if (!empty($users)): ?>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 text-primary focus:ring-primary">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('full_name', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
User <?= sortIcon('full_name', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('username', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
Username <?= sortIcon('username', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('role', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
Role <?= sortIcon('role', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('is_active', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
Status <?= sortIcon('is_active', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('email_verified', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
Email Verified <?= sortIcon('email_verified', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('last_login', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
Last Login <?= sortIcon('last_login', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($users as $user): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
<?php if ($user['id'] != \Core\Auth::id()): ?>
|
||||
<input type="checkbox" class="user-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= $user['id'] ?>" onchange="updateBulkActions()">
|
||||
<?php else: ?>
|
||||
<span class="text-gray-300" title="Cannot select your own account">
|
||||
<i class="fas fa-lock text-xs"></i>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<?php
|
||||
// Get avatar data for this user (now fast with database caching)
|
||||
$avatar = \App\Helpers\AvatarHelper::getAvatar($user, 40);
|
||||
?>
|
||||
<div class="flex-shrink-0 h-10 w-10 rounded-lg overflow-hidden bg-primary bg-opacity-10 flex items-center justify-center">
|
||||
<?php if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar'): ?>
|
||||
<img src="<?= htmlspecialchars($avatar['url']) ?>"
|
||||
alt="<?= htmlspecialchars($avatar['alt']) ?>"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy">
|
||||
<?php else: ?>
|
||||
<span class="text-primary font-semibold text-sm">
|
||||
<?= $avatar['initials'] ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? 'N/A') ?></div>
|
||||
<div class="text-xs text-gray-500"><?= htmlspecialchars($user['email']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-900"><?= htmlspecialchars($user['username']) ?></span>
|
||||
<?php if (!empty($user['two_factor_enabled'])): ?>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px] font-semibold border border-green-200" title="Two-factor authentication enabled">
|
||||
<i class="fas fa-shield-alt mr-0.5"></i>2FA
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-gray-100 text-gray-400 rounded text-[10px] font-medium border border-gray-200" title="Two-factor authentication not enabled">
|
||||
<i class="fas fa-shield-alt mr-0.5"></i>No 2FA
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border
|
||||
<?= $user['role'] === 'admin' ? 'bg-amber-100 text-amber-700 border-amber-200' : 'bg-blue-100 text-blue-700 border-blue-200' ?>">
|
||||
<i class="fas fa-<?= $user['role'] === 'admin' ? 'crown' : 'user' ?> mr-1"></i>
|
||||
<?= ucfirst($user['role']) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border
|
||||
<?= $user['is_active'] ? 'bg-green-100 text-green-700 border-green-200' : 'bg-red-100 text-red-700 border-red-200' ?>">
|
||||
<i class="fas fa-<?= $user['is_active'] ? 'check-circle' : 'times-circle' ?> mr-1"></i>
|
||||
<?= $user['is_active'] ? 'Active' : 'Inactive' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<?php if ($user['email_verified']): ?>
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
<span class="text-sm text-gray-900">Verified</span>
|
||||
<?php else: ?>
|
||||
<i class="fas fa-times-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-gray-500">Not Verified</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<?php if ($user['last_login']): ?>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
<?= date('M d, H:i', strtotime($user['last_login'])) ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-gray-400">Never</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/users/<?= $user['id'] ?>" class="text-gray-600 hover:text-primary" title="View Profile">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="/users/<?= $user['id'] ?>/edit" class="text-blue-600 hover:text-blue-800" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<?php if ($user['id'] != \Core\Auth::id()): ?>
|
||||
<a href="#"
|
||||
class="text-orange-600 hover:text-orange-800"
|
||||
title="<?= $user['is_active'] ? 'Deactivate' : 'Activate' ?>"
|
||||
onclick="toggleUserStatus(<?= $user['id'] ?>); return false;">
|
||||
<i class="fas fa-<?= $user['is_active'] ? 'user-slash' : 'user-check' ?>"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="text-red-600 hover:text-red-800"
|
||||
title="Delete"
|
||||
onclick="deleteUser(<?= $user['id'] ?>); return false;">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="text-gray-400" title="Cannot modify your own account">
|
||||
<i class="fas fa-lock"></i>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- Empty State -->
|
||||
<div class="p-12 text-center">
|
||||
<i class="fas fa-users text-gray-300 text-6xl mb-4"></i>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Users Yet</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Start by adding your first user</p>
|
||||
<a href="/users/create" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Add Your First User
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<?php if ($pagination['total_pages'] > 1): ?>
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Page Info -->
|
||||
<div class="text-sm text-gray-600">
|
||||
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?></span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
<?php
|
||||
// Helper function to build pagination URL
|
||||
function paginationUrl($page, $filters, $perPage) {
|
||||
$params = $filters;
|
||||
$params['page'] = $page;
|
||||
$params['per_page'] = $perPage;
|
||||
return '/users?' . http_build_query($params);
|
||||
}
|
||||
|
||||
$currentPage = $pagination['current_page'];
|
||||
$totalPages = $pagination['total_pages'];
|
||||
?>
|
||||
|
||||
<!-- First Page -->
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl(1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Previous Page -->
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl($currentPage - 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Page Numbers -->
|
||||
<?php
|
||||
$range = 2;
|
||||
$start = max(1, $currentPage - $range);
|
||||
$end = min($totalPages, $currentPage + $range);
|
||||
|
||||
if ($start > 1) {
|
||||
echo '<a href="' . paginationUrl(1, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
|
||||
if ($start > 2) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
}
|
||||
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
if ($i == $currentPage) {
|
||||
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
|
||||
} else {
|
||||
echo '<a href="' . paginationUrl($i, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
if ($end < $totalPages) {
|
||||
if ($end < $totalPages - 1) {
|
||||
echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
echo '<a href="' . paginationUrl($totalPages, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Next Page -->
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($currentPage + 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Last Page -->
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($totalPages, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox:checked');
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
selectedCount.textContent = checkboxes.length + ' user(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.user-checkbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkboxes.length > 0 && checkboxes.length < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
document.getElementById('select-all').checked = false;
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function getSelectedUserIds() {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkToggleStatus(action) {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
alert('Please select at least one user');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionText = action === 'active' ? 'activate' : 'deactivate';
|
||||
if (!confirm(`Are you sure you want to ${actionText} ${userIds.length} user(s)?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/bulk-toggle-status';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'user_ids';
|
||||
idsInput.value = JSON.stringify(userIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
const actionInput = document.createElement('input');
|
||||
actionInput.type = 'hidden';
|
||||
actionInput.name = 'action';
|
||||
actionInput.value = action;
|
||||
form.appendChild(actionInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function toggleUserStatus(userId) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/' + userId + '/toggle-status';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteUser(userId) {
|
||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/' + userId + '/delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkDeleteUsers() {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
alert('Please select at least one user to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${userIds.length} user(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/bulk-delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'user_ids';
|
||||
idsInput.value = JSON.stringify(userIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
|
||||
504
app/Views/users/index.twig
Normal file
504
app/Views/users/index.twig
Normal file
@@ -0,0 +1,504 @@
|
||||
{% extends 'layout/base.twig' %}
|
||||
|
||||
{% set title = 'User Management' %}
|
||||
{% set pageTitle = 'User Management' %}
|
||||
{% set pageDescription = 'Manage system users and permissions' %}
|
||||
{% set pageIcon = 'fas fa-users' %}
|
||||
|
||||
{% set currentFilters = filters|default({search: '', role: '', status: '', sort: 'username', order: 'asc'}) %}
|
||||
{% set pagination = pagination|default({current_page: 1, total_pages: 1, per_page: 25, total: users|length, showing_from: 1, showing_to: users|length}) %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex justify-end">
|
||||
<a href="/users/create" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Add User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-5 mb-4">
|
||||
<form method="GET" action="/users" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Search</label>
|
||||
<div class="relative">
|
||||
<input type="text" name="search" value="{{ currentFilters.search }}" placeholder="Search users..." class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Role</label>
|
||||
<select name="role" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin" {{ currentFilters.role == 'admin' ? 'selected' : '' }}>Admin</option>
|
||||
<option value="user" {{ currentFilters.role == 'user' ? 'selected' : '' }}>User</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Status</label>
|
||||
<select name="status" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active" {{ currentFilters.status == 'active' ? 'selected' : '' }}>Active</option>
|
||||
<option value="inactive" {{ currentFilters.status == 'inactive' ? 'selected' : '' }}>Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Apply/Reset Buttons -->
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="/users" class="px-4 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="sort" value="{{ currentFilters.sort }}">
|
||||
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_from }}</span> to
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_to }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total }}</span> user(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/users" class="flex items-center gap-2">
|
||||
<!-- Preserve current filters -->
|
||||
<input type="hidden" name="search" value="{{ currentFilters.search }}">
|
||||
<input type="hidden" name="role" value="{{ currentFilters.role }}">
|
||||
<input type="hidden" name="status" value="{{ currentFilters.status }}">
|
||||
<input type="hidden" name="sort" value="{{ currentFilters.sort }}">
|
||||
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600 dark:text-slate-400">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
||||
<option value="10" {{ pagination.per_page == 10 ? 'selected' : '' }}>10</option>
|
||||
<option value="25" {{ pagination.per_page == 25 ? 'selected' : '' }}>25</option>
|
||||
<option value="50" {{ pagination.per_page == 50 ? 'selected' : '' }}>50</option>
|
||||
<option value="100" {{ pagination.per_page == 100 ? 'selected' : '' }}>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when users are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 dark:bg-blue-500/10 border-b border-blue-200 dark:border-blue-500/20 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700 dark:text-slate-300"></span>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" onclick="bulkToggleStatus('active')" class="inline-flex items-center px-4 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-user-check mr-1"></i> Activate Selected
|
||||
</button>
|
||||
<button type="button" onclick="bulkToggleStatus('inactive')" class="inline-flex items-center px-4 py-1.5 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-user-slash mr-1"></i> Deactivate Selected
|
||||
</button>
|
||||
<button type="button" onclick="bulkDeleteUsers()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-1"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-slate-400 hover:text-gray-900 dark:hover:text-white hover:bg-blue-100 dark:hover:bg-blue-500/20 px-3 py-1.5 rounded-lg transition-colors">
|
||||
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
{% if users is not empty %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
||||
<thead class="bg-gray-50 dark:bg-slate-900">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('full_name', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
User {{ sort_icon('full_name', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('username', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Username {{ sort_icon('username', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('role', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Role {{ sort_icon('role', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('is_active', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Status {{ sort_icon('is_active', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('email_verified', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Email Verified {{ sort_icon('email_verified', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
|
||||
<a href="{{ sort_url('last_login', currentFilters.sort, currentFilters.order, currentFilters) }}" class="hover:text-primary flex items-center">
|
||||
Last Login {{ sort_icon('last_login', currentFilters.sort, currentFilters.order) }}
|
||||
</a>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
||||
{% for user in users %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
{% if user.id != auth.id %}
|
||||
<input type="checkbox" class="user-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="{{ user.id }}" onchange="updateBulkActions()">
|
||||
{% else %}
|
||||
<span class="text-gray-300 dark:text-slate-600" title="Cannot select your own account">
|
||||
<i class="fas fa-lock text-xs"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 rounded-lg overflow-hidden bg-primary bg-opacity-10 flex items-center justify-center">
|
||||
{% if user.avatar.type == 'uploaded' or user.avatar.type == 'gravatar' %}
|
||||
<img src="{{ user.avatar.url }}"
|
||||
alt="{{ user.avatar.alt }}"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<span class="text-primary font-semibold text-sm">
|
||||
{{ user.avatar.initials }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ user.full_name|default('N/A') }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-slate-400">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ user.username }}</span>
|
||||
{% if user.two_factor_enabled %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-700 dark:text-green-400 rounded text-[10px] font-semibold border border-green-200 dark:border-green-500/20" title="Two-factor authentication enabled">
|
||||
<i class="fas fa-shield-alt mr-0.5"></i>2FA
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 rounded text-[10px] font-medium border border-gray-200 dark:border-slate-600" title="Two-factor authentication not enabled">
|
||||
<i class="fas fa-shield-alt mr-0.5"></i>No 2FA
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{{ role_badge(user.role) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border
|
||||
{{ user.is_active ? 'bg-green-100 text-green-700 border-green-200 dark:bg-green-500/10 dark:text-green-400 dark:border-green-500/20' : 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20' }}">
|
||||
<i class="fas fa-{{ user.is_active ? 'check-circle' : 'times-circle' }} mr-1"></i>
|
||||
{{ user.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
{% if user.email_verified %}
|
||||
<i class="fas fa-check-circle text-green-500 mr-2"></i>
|
||||
<span class="text-sm text-gray-900 dark:text-white">Verified</span>
|
||||
{% else %}
|
||||
<i class="fas fa-times-circle text-red-500 mr-2"></i>
|
||||
<span class="text-sm text-gray-500 dark:text-slate-400">Not Verified</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-slate-400">
|
||||
{% if user.last_login %}
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
{{ user.last_login|date('M d, H:i') }}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-slate-500">Never</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/users/{{ user.id }}" class="text-gray-600 dark:text-slate-400 hover:text-primary" title="View Profile">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<a href="/users/{{ user.id }}/edit" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
{% if user.id != auth.id %}
|
||||
<a href="#"
|
||||
class="text-orange-600 hover:text-orange-800 dark:text-orange-400 dark:hover:text-orange-300"
|
||||
title="{{ user.is_active ? 'Deactivate' : 'Activate' }}"
|
||||
onclick="toggleUserStatus({{ user.id }}); return false;">
|
||||
<i class="fas fa-{{ user.is_active ? 'user-slash' : 'user-check' }}"></i>
|
||||
</a>
|
||||
<a href="#"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete"
|
||||
onclick="deleteUser({{ user.id }}); return false;">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-slate-500" title="Cannot modify your own account">
|
||||
<i class="fas fa-lock"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="p-12 text-center">
|
||||
<i class="fas fa-users text-gray-300 dark:text-slate-600 text-6xl mb-4"></i>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-slate-300 mb-1">No Users Yet</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">Start by adding your first user</p>
|
||||
<a href="/users/create" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Add Your First User
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{% if pagination.total_pages > 1 %}
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<!-- Page Info -->
|
||||
<div class="text-sm text-gray-600 dark:text-slate-400">
|
||||
Page <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.current_page }}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total_pages }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Buttons -->
|
||||
<div class="flex items-center gap-1">
|
||||
{% set currentPage = pagination.current_page %}
|
||||
{% set totalPages = pagination.total_pages %}
|
||||
|
||||
{# First Page #}
|
||||
{% if currentPage > 1 %}
|
||||
<a href="{{ pagination_url(1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Previous Page #}
|
||||
{% if currentPage > 1 %}
|
||||
<a href="{{ pagination_url(currentPage - 1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Page Numbers #}
|
||||
{% set startPage = (currentPage - 2) > 1 ? (currentPage - 2) : 1 %}
|
||||
{% set endPage = (currentPage + 2) < totalPages ? (currentPage + 2) : totalPages %}
|
||||
|
||||
{% if startPage > 1 %}
|
||||
<a href="{{ pagination_url(1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">1</a>
|
||||
{% if startPage > 2 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% for i in startPage..endPage %}
|
||||
{% if i == currentPage %}
|
||||
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">{{ i }}</span>
|
||||
{% else %}
|
||||
<a href="{{ pagination_url(i, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ i }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if endPage < totalPages %}
|
||||
{% if endPage < totalPages - 1 %}
|
||||
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
||||
{% endif %}
|
||||
<a href="{{ pagination_url(totalPages, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">{{ totalPages }}</a>
|
||||
{% endif %}
|
||||
|
||||
{# Next Page #}
|
||||
{% if currentPage < totalPages %}
|
||||
<a href="{{ pagination_url(currentPage + 1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Last Page #}
|
||||
{% if currentPage < totalPages %}
|
||||
<a href="{{ pagination_url(totalPages, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-gray-700 dark:text-slate-300">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox:checked');
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
selectedCount.textContent = checkboxes.length + ' user(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
}
|
||||
|
||||
const allCheckboxes = document.querySelectorAll('.user-checkbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkboxes.length > 0 && checkboxes.length < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
document.getElementById('select-all').checked = false;
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function getSelectedUserIds() {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkToggleStatus(action) {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
alert('Please select at least one user');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionText = action === 'active' ? 'activate' : 'deactivate';
|
||||
if (!confirm(`Are you sure you want to ${actionText} ${userIds.length} user(s)?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/bulk-toggle-status';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'user_ids';
|
||||
idsInput.value = JSON.stringify(userIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
const actionInput = document.createElement('input');
|
||||
actionInput.type = 'hidden';
|
||||
actionInput.name = 'action';
|
||||
actionInput.value = action;
|
||||
form.appendChild(actionInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function toggleUserStatus(userId) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/' + userId + '/toggle-status';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteUser(userId) {
|
||||
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/' + userId + '/delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkDeleteUsers() {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
alert('Please select at least one user to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${userIds.length} user(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/bulk-delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'user_ids';
|
||||
idsInput.value = JSON.stringify(userIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,15 @@ abstract class Controller
|
||||
{
|
||||
protected function view(string $view, array $data = []): void
|
||||
{
|
||||
$twigPath = __DIR__ . "/../app/Views/$view.twig";
|
||||
|
||||
if (file_exists($twigPath)) {
|
||||
$twig = TwigService::getInstance();
|
||||
echo $twig->render("$view.twig", $data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to legacy PHP view during migration
|
||||
extract($data);
|
||||
$viewPath = __DIR__ . "/../app/Views/$view.php";
|
||||
|
||||
@@ -13,7 +22,7 @@ abstract class Controller
|
||||
throw new \Exception("View not found: $view");
|
||||
}
|
||||
|
||||
require_once $viewPath;
|
||||
require $viewPath;
|
||||
}
|
||||
|
||||
protected function json($data, int $status = 200): void
|
||||
|
||||
@@ -72,7 +72,12 @@ class Router
|
||||
// Silently fail if logging is not available
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../app/Views/errors/404.php';
|
||||
$twigPath = __DIR__ . '/../app/Views/errors/404.twig';
|
||||
if (file_exists($twigPath)) {
|
||||
echo TwigService::getInstance()->render('errors/404.twig');
|
||||
} else {
|
||||
require_once __DIR__ . '/../app/Views/errors/404.php';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
253
core/TwigService.php
Normal file
253
core/TwigService.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace Core;
|
||||
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
use Twig\Extension\DebugExtension;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigFilter;
|
||||
|
||||
class TwigService
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
private Environment $twig;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$viewsPath = PATH_ROOT . 'app/Views';
|
||||
$loader = new FilesystemLoader($viewsPath);
|
||||
|
||||
$isDev = ($_ENV['APP_ENV'] ?? 'development') === 'development';
|
||||
$cachePath = $isDev ? false : PATH_ROOT . 'cache/twig';
|
||||
|
||||
$this->twig = new Environment($loader, [
|
||||
'cache' => $cachePath,
|
||||
'debug' => $isDev,
|
||||
'auto_reload' => $isDev,
|
||||
'strict_variables' => false,
|
||||
'autoescape' => 'html',
|
||||
]);
|
||||
|
||||
if ($isDev) {
|
||||
$this->twig->addExtension(new DebugExtension());
|
||||
}
|
||||
|
||||
$this->registerFunctions();
|
||||
$this->registerFilters();
|
||||
}
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function getEnvironment(): Environment
|
||||
{
|
||||
return $this->twig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Twig template with data + automatically injected globals.
|
||||
*/
|
||||
public function render(string $template, array $data = []): string
|
||||
{
|
||||
$globals = $this->getGlobals();
|
||||
$context = array_merge($globals, $data);
|
||||
|
||||
return $this->twig->render($template, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect layout-level data that every template may need.
|
||||
* Computed on each render so values are always fresh.
|
||||
*/
|
||||
private function getGlobals(): array
|
||||
{
|
||||
// Session flash messages (read & clear) — always safe
|
||||
$flash = [];
|
||||
foreach (['success', 'error', 'warning', 'info'] as $type) {
|
||||
if (isset($_SESSION[$type])) {
|
||||
$flash[$type] = $_SESSION[$type];
|
||||
unset($_SESSION[$type]);
|
||||
}
|
||||
}
|
||||
|
||||
// Database-dependent globals are wrapped in try/catch so standalone
|
||||
// pages (installer, error pages) still render when the DB is absent.
|
||||
try {
|
||||
$userId = Auth::id();
|
||||
|
||||
if ($userId) {
|
||||
$notificationData = \App\Helpers\LayoutHelper::getNotifications($userId);
|
||||
$recentNotifications = $notificationData['items'];
|
||||
$unreadNotifications = $notificationData['unread_count'];
|
||||
$updateBadge = Auth::isAdmin()
|
||||
? \App\Helpers\LayoutHelper::getUpdateBadgeInfo()
|
||||
: ['show' => false, 'available' => false, 'label' => ''];
|
||||
} else {
|
||||
$recentNotifications = [];
|
||||
$unreadNotifications = 0;
|
||||
$updateBadge = ['show' => false, 'available' => false, 'label' => ''];
|
||||
}
|
||||
|
||||
$domainStats = \App\Helpers\LayoutHelper::getDomainStats();
|
||||
$appSettings = \App\Helpers\LayoutHelper::getAppSettings();
|
||||
|
||||
$avatar = null;
|
||||
if ($userId) {
|
||||
$userModel = new \App\Models\User();
|
||||
$user = $userModel->find($userId);
|
||||
if ($user) {
|
||||
$avatar = \App\Helpers\AvatarHelper::getAvatar($user, 36);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'auth' => [
|
||||
'check' => Auth::check(),
|
||||
'id' => $userId,
|
||||
'username' => Auth::username(),
|
||||
'fullName' => Auth::fullName(),
|
||||
'role' => Auth::role(),
|
||||
'isAdmin' => Auth::isAdmin(),
|
||||
],
|
||||
'session' => $_SESSION ?? [],
|
||||
'flash' => $flash,
|
||||
'recentNotifications' => $recentNotifications,
|
||||
'unreadNotifications' => $unreadNotifications,
|
||||
'updateBadge' => $updateBadge,
|
||||
'domainStats' => $domainStats,
|
||||
'appName' => $appSettings['app_name'],
|
||||
'appTimezone' => $appSettings['app_timezone'],
|
||||
'appVersion' => $appSettings['app_version'],
|
||||
'avatar' => $avatar,
|
||||
'currentUrl' => $_SERVER['REQUEST_URI'] ?? '/',
|
||||
'appEnv' => $_ENV['APP_ENV'] ?? 'development',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'auth' => ['check' => false, 'id' => null, 'username' => '', 'fullName' => '', 'role' => '', 'isAdmin' => false],
|
||||
'session' => $_SESSION ?? [],
|
||||
'flash' => $flash,
|
||||
'recentNotifications' => [],
|
||||
'unreadNotifications' => 0,
|
||||
'updateBadge' => ['show' => false, 'available' => false, 'label' => ''],
|
||||
'domainStats' => [],
|
||||
'appName' => 'Domain Monitor',
|
||||
'appTimezone' => 'UTC',
|
||||
'appVersion' => '',
|
||||
'avatar' => null,
|
||||
'currentUrl' => $_SERVER['REQUEST_URI'] ?? '/',
|
||||
'appEnv' => $_ENV['APP_ENV'] ?? 'development',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function registerFunctions(): void
|
||||
{
|
||||
$this->twig->addFunction(new TwigFunction('csrf_field', function (): string {
|
||||
return \Core\Csrf::field();
|
||||
}, ['is_safe' => ['html']]));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('csrf_token', function (): string {
|
||||
return \Core\Csrf::getToken();
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('old', function (string $key, string $default = ''): string {
|
||||
return htmlspecialchars($_POST[$key] ?? $default, ENT_QUOTES, 'UTF-8');
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('asset', function (string $path): string {
|
||||
return '/' . ltrim($path, '/');
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('is_active', function (string $path): bool {
|
||||
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
return $current === $path;
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('is_active_prefix', function (string $prefix): bool {
|
||||
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
return str_starts_with($current, $prefix);
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('sort_url', function (string $column, string $currentSort, string $currentOrder, array $filters = []): string {
|
||||
return \App\Helpers\ViewHelper::sortUrl($column, $currentSort, $currentOrder, $filters);
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('sort_icon', function (string $column, string $currentSort, string $currentOrder): string {
|
||||
return \App\Helpers\ViewHelper::sortIcon($column, $currentSort, $currentOrder);
|
||||
}, ['is_safe' => ['html']]));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('pagination_url', function (int $page, array $filters, int $perPage): string {
|
||||
return \App\Helpers\ViewHelper::paginationUrl($page, $filters, $perPage);
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('status_badge', function (string $status): string {
|
||||
return \App\Helpers\ViewHelper::statusBadge($status);
|
||||
}, ['is_safe' => ['html']]));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('alert', function (string $type, string $message): string {
|
||||
return \App\Helpers\ViewHelper::alert($type, $message);
|
||||
}, ['is_safe' => ['html']]));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('breadcrumbs', function (array $items): string {
|
||||
return \App\Helpers\ViewHelper::breadcrumbs($items);
|
||||
}, ['is_safe' => ['html']]));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('format_login_dropdown', function (array $loginData): string {
|
||||
return \App\Helpers\LayoutHelper::formatLoginDropdown($loginData);
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('max_upload_size', function (): string {
|
||||
return \App\Helpers\ViewHelper::getMaxUploadSize();
|
||||
}));
|
||||
|
||||
$this->twig->addFunction(new TwigFunction('role_badge', function (string $role, string $size = 'sm'): string {
|
||||
$isAdmin = $role === 'admin';
|
||||
$color = $isAdmin ? 'amber' : 'blue';
|
||||
$icon = $isAdmin ? 'crown' : 'user';
|
||||
$label = ucfirst($role);
|
||||
|
||||
if ($size === 'xs') {
|
||||
$padding = 'px-2 py-0.5';
|
||||
} else {
|
||||
$padding = 'px-2.5 py-1';
|
||||
}
|
||||
|
||||
return '<span class="inline-flex items-center ' . $padding . ' rounded-full text-xs font-semibold '
|
||||
. 'bg-' . $color . '-100 dark:bg-' . $color . '-500/10 '
|
||||
. 'text-' . $color . '-700 dark:text-' . $color . '-400 '
|
||||
. 'border border-' . $color . '-200 dark:border-' . $color . '-500/20">'
|
||||
. '<i class="fas fa-' . $icon . ' mr-1"></i>'
|
||||
. htmlspecialchars($label)
|
||||
. '</span>';
|
||||
}, ['is_safe' => ['html']]));
|
||||
}
|
||||
|
||||
private function registerFilters(): void
|
||||
{
|
||||
$this->twig->addFilter(new TwigFilter('truncate', function (string $text, int $length = 50, string $suffix = '...'): string {
|
||||
return \App\Helpers\ViewHelper::truncate($text, $length, $suffix);
|
||||
}));
|
||||
|
||||
$this->twig->addFilter(new TwigFilter('format_bytes', function (int $bytes, int $precision = 2): string {
|
||||
return \App\Helpers\ViewHelper::formatBytes($bytes, $precision);
|
||||
}));
|
||||
|
||||
$this->twig->addFilter(new TwigFilter('from_json', function ($value) {
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
}
|
||||
if (is_array($value) || is_object($value)) {
|
||||
return $value;
|
||||
}
|
||||
$decoded = json_decode((string) $value, true);
|
||||
return $decoded ?? [];
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user