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:
Hosteroid
2026-03-03 18:21:32 +02:00
parent cd4e3e6bcc
commit 4818172bc6
73 changed files with 9948 additions and 10686 deletions

View File

@@ -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'
]);
}

View File

@@ -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'
]);
}

View File

@@ -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,
]);
}

View File

@@ -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';

View File

@@ -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);
}
}
/**

View File

@@ -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';
?>

View 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 %}

View File

@@ -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
View 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 %}

View File

@@ -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
View 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 %}

View File

@@ -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 %}

View File

@@ -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>

View 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>

View File

@@ -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';
?>

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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';
?>

View 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 %}

View File

@@ -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 %}

View File

@@ -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&#10;google.com&#10;github.com&#10;..."
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 &middot; Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
<p class="mt-3 text-xs text-gray-400 dark:text-slate-500">CSV, JSON &middot; 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 %}

View File

@@ -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 %}

View File

@@ -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

View File

@@ -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
View 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 %}

View File

@@ -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> &copy; {{ "now"|date("Y") }}</span>
</p>
</div>
</div>

View File

@@ -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 &copy; <?= date('Y') ?></span>
<span class="ml-2">Domain Monitor &copy; {{ "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>

View File

@@ -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';
?>

View 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 %}

View File

@@ -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';
?>

View 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 }}&times;
</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 %}

View File

@@ -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 &copy; <?= date('Y') ?> • Development Mode
Domain Monitor &copy; {{ "now"|date("Y") }} &bull; 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>

View File

@@ -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

View File

@@ -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 &middot; Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
<p class="mt-2.5 text-xs text-gray-400 dark:text-slate-500">CSV, JSON &middot; 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 %}

View File

@@ -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 &rarr; Email)</li>
<li>Import TLD registry data (TLD Registry &rarr; 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">&copy; {{ "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>

View File

@@ -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">&copy; {{ "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>

View File

@@ -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">&copy; {{ "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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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') }} &middot; {{ 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') }} &middot; {{ 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>

View File

@@ -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';
?>

View 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 %}

View File

@@ -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 %}

View File

@@ -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';
?>

View 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

View File

@@ -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
View 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 %}

View File

@@ -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';
?>

View 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 %}

View File

@@ -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 %}

View File

@@ -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 &middot; 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';
?>

View 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 &middot; 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 %}

View File

@@ -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/&#10;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';
?>

View 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/&#10;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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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
View 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 ?? [];
}));
}
}