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\SessionManager;
use App\Models\RememberToken; use App\Models\RememberToken;
use App\Services\Logger; use App\Services\Logger;
use App\Services\TwoFactorService;
use App\Helpers\AvatarHelper; use App\Helpers\AvatarHelper;
class ProfileController extends Controller class ProfileController extends Controller
@@ -71,10 +72,30 @@ class ProfileController extends Controller
// Format sessions for display (adds deviceIcon, browserInfo, timeAgo, sessionAge) // Format sessions for display (adds deviceIcon, browserInfo, timeAgo, sessionAge)
$formattedSessions = \App\Helpers\SessionHelper::formatForDisplay($sessions); $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', [ $this->view('profile/index', [
'user' => $user, 'user' => $user,
'sessions' => $formattedSessions, 'sessions' => $formattedSessions,
'userModel' => $this->userModel, 'avatar' => $avatar,
'twoFactorStatus' => $twoFactorStatus,
'twoFactorPolicy' => $twoFactorPolicy,
'title' => 'My Profile' 'title' => 'My Profile'
]); ]);
} }

View File

@@ -70,6 +70,72 @@ class SettingsController extends Controller
// Status notification triggers // Status notification triggers
$statusTriggers = $this->settingModel->getNotificationStatusTriggers(); $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', [ $this->view('settings/index', [
'settings' => $settings, 'settings' => $settings,
'appSettings' => $appSettings, 'appSettings' => $appSettings,
@@ -81,6 +147,13 @@ class SettingsController extends Controller
'notificationPresets' => $notificationPresets, 'notificationPresets' => $notificationPresets,
'checkIntervalPresets' => $checkIntervalPresets, 'checkIntervalPresets' => $checkIntervalPresets,
'statusTriggers' => $statusTriggers, 'statusTriggers' => $statusTriggers,
'popularTimezones' => $popularTimezones,
'allTimezones' => $allTimezones,
'selectedPreset' => $selectedPreset,
'cronPath' => $cronPath,
'cachedUpdateAvailable' => $cachedUpdateAvailable,
'cachedUpdateData' => $cachedUpdateData,
'rollbackAvailable' => $rollbackAvailable,
'title' => 'Settings' 'title' => 'Settings'
]); ]);
} }

View File

@@ -50,6 +50,11 @@ class UserController extends Controller
// Get filtered users // Get filtered users
$users = $this->userModel->getFiltered($filters, $sort, strtoupper($order), $perPage, $offset); $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', [ $this->view('users/index', [
'users' => $users, 'users' => $users,
'title' => 'User Management', 'title' => 'User Management',
@@ -240,6 +245,17 @@ class UserController extends Controller
// Get 2FA status // Get 2FA status
$twoFactorStatus = $this->userModel->getTwoFactorStatus($userId); $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', [ $this->view('users/show', [
'title' => htmlspecialchars($user['full_name']) . ' - User Profile', 'title' => htmlspecialchars($user['full_name']) . ' - User Profile',
'user' => $user, 'user' => $user,
@@ -248,6 +264,8 @@ class UserController extends Controller
'tags' => $tags, 'tags' => $tags,
'groups' => $groups, 'groups' => $groups,
'twoFactorStatus' => $twoFactorStatus, 'twoFactorStatus' => $twoFactorStatus,
'userAvatar' => $userAvatar,
'registrarCounts' => $registrarCounts,
]); ]);
} }

View File

@@ -297,11 +297,33 @@ class ErrorHandler
$session_data = json_decode($errorData['session_data'], true); $session_data = json_decode($errorData['session_data'], true);
// Display debug page in development, clean 500 in production // Display debug page in development, clean 500 in production
$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) { if ($this->isDevelopment) {
require __DIR__ . '/../Views/errors/debug.php'; require __DIR__ . '/../Views/errors/debug.php';
} else { } else {
require __DIR__ . '/../Views/errors/500.php'; require __DIR__ . '/../Views/errors/500.php';
} }
}
} elseif ($this->isDevelopment) {
require __DIR__ . '/../Views/errors/debug.php';
} else {
require __DIR__ . '/../Views/errors/500.php';
}
exit; exit;
} }

View File

@@ -38,6 +38,9 @@ class TwoFactorService
*/ */
public function generateQrCodeDataUri(string $email, string $secret, string $appName = 'Domain Monitor'): string public function generateQrCodeDataUri(string $email, string $secret, string $appName = 'Domain Monitor'): string
{ {
$previousLevel = error_reporting(error_reporting() & ~E_DEPRECATED);
try {
$qrCode = new QrCode($this->google2fa->getQRCodeUrl($appName, $email, $secret)); $qrCode = new QrCode($this->google2fa->getQRCodeUrl($appName, $email, $secret));
$qrCode->setSize(200); $qrCode->setSize(200);
$qrCode->setMargin(10); $qrCode->setMargin(10);
@@ -46,6 +49,9 @@ class TwoFactorService
$result = $writer->write($qrCode); $result = $writer->write($qrCode);
return 'data:image/png;base64,' . base64_encode($result->getString()); 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
* CAPTCHA Widget Component # Renders the appropriate CAPTCHA widget based on settings
* Renders the appropriate CAPTCHA widget based on settings #
* # Required variables:
* Required variables: # - captchaSettings: Array with 'provider' and 'site_key'
* - $captchaSettings: Array with 'provider' and 'site_key' #}
*/
$provider = $captchaSettings['provider'] ?? 'disabled'; {% set provider = captchaSettings.provider|default('disabled') %}
$siteKey = $captchaSettings['site_key'] ?? ''; {% set siteKey = captchaSettings.site_key|default('') %}
if ($provider === 'disabled' || empty($siteKey)) { {% if provider != 'disabled' and siteKey is not empty %}
return; // No CAPTCHA to render {# CAPTCHA Widget #}
}
?>
<!-- CAPTCHA Widget -->
<div class="captcha-container mb-4"> <div class="captcha-container mb-4">
<?php if ($provider === 'recaptcha_v2'): ?> {% if provider == 'recaptcha_v2' %}
<!-- reCAPTCHA v2 --> {# reCAPTCHA v2 #}
<div class="g-recaptcha" data-sitekey="<?= htmlspecialchars($siteKey) ?>"></div> <div class="g-recaptcha" data-sitekey="{{ siteKey }}"></div>
<script src="https://www.google.com/recaptcha/api.js" async defer></script> <script src="https://www.google.com/recaptcha/api.js" async defer></script>
<?php elseif ($provider === 'recaptcha_v3'): ?> {% elseif provider == 'recaptcha_v3' %}
<!-- reCAPTCHA v3 (Invisible) --> {# reCAPTCHA v3 (Invisible) #}
<input type="hidden" id="captcha_response" name="captcha_response"> <input type="hidden" id="captcha_response" name="captcha_response">
<script src="https://www.google.com/recaptcha/api.js?render=<?= htmlspecialchars($siteKey) ?>"></script> <script src="https://www.google.com/recaptcha/api.js?render={{ siteKey }}"></script>
<?php elseif ($provider === 'turnstile'): ?> {% elseif provider == 'turnstile' %}
<!-- Cloudflare Turnstile --> {# Cloudflare Turnstile #}
<div class="cf-turnstile" data-sitekey="<?= htmlspecialchars($siteKey) ?>"></div> <div class="cf-turnstile" data-sitekey="{{ siteKey }}"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<?php endif; ?>
</div> </div>
<?php if ($provider === 'recaptcha_v3'): ?> {% if provider == 'recaptcha_v3' %}
<!-- reCAPTCHA v3 Form Submission Handler --> {# reCAPTCHA v3 Form Submission Handler #}
<script> <script>
// Store the original form submission handler // Store the original form submission handler
const form = document.querySelector('form'); const form = document.querySelector('form');
@@ -46,7 +40,7 @@ if ($provider === 'disabled' || empty($siteKey)) {
e.preventDefault(); e.preventDefault();
grecaptcha.ready(function() { 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; document.getElementById('captcha_response').value = token;
// Call original submit handler if it exists // Call original submit handler if it exists
@@ -60,8 +54,8 @@ if ($provider === 'disabled' || empty($siteKey)) {
}); });
}); });
</script> </script>
<?php elseif ($provider === 'recaptcha_v2' || $provider === 'turnstile'): ?> {% elseif provider == 'recaptcha_v2' or provider == 'turnstile' %}
<!-- reCAPTCHA v2 / Turnstile Response Handler --> {# reCAPTCHA v2 / Turnstile Response Handler #}
<script> <script>
// Add hidden input to capture response // Add hidden input to capture response
const form = document.querySelector('form'); const form = document.querySelector('form');
@@ -73,14 +67,14 @@ if ($provider === 'disabled' || empty($siteKey)) {
// Capture response on form submit // Capture response on form submit
form.addEventListener('submit', function(e) { form.addEventListener('submit', function(e) {
<?php if ($provider === 'recaptcha_v2'): ?> {% if provider == 'recaptcha_v2' %}
const response = grecaptcha.getResponse(); const response = grecaptcha.getResponse();
<?php else: // turnstile ?> {% else %}{# turnstile #}
const response = turnstile.getResponse(); const response = turnstile.getResponse();
<?php endif; ?> {% endif %}
captchaInput.value = response; captchaInput.value = response;
}); });
</script> </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'; # Login Page
ob_start(); #}
?> {% extends 'auth/base-auth.twig' %}
<!-- Logo and Title --> {% set title = 'Login' %}
{% block content %}
{# Logo and Title #}
<div class="text-center mb-8"> <div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4"> <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> <i class="fas fa-globe text-white text-2xl"></i>
</div> </div>
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Welcome Back</h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-1">Welcome Back</h1>
<p class="text-sm text-gray-500">Sign in to access your account</p> <p class="text-sm text-gray-500 dark:text-gray-400">Sign in to access your account</p>
</div> </div>
<!-- Error Alert --> {# Success Alert #}
<?php if (isset($_SESSION['error'])): ?> {% if flash.success is defined %}
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg"> <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"> <div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i> <i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span> <span class="text-sm text-green-700 dark:text-green-300">{{ flash.success }}</span>
</div> </div>
</div> </div>
<?php unset($_SESSION['error']); ?> {% endif %}
<?php 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"> <form method="POST" action="/login" class="space-y-5">
<?= csrf_field() ?> {{ csrf_field() }}
<!-- Username Field -->
{# Username Field #}
<div> <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 Username or Email
</label> </label>
<div class="relative"> <div class="relative">
@@ -41,14 +54,14 @@ ob_start();
name="username" name="username"
required required
autofocus 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"> placeholder="Enter your username or email">
</div> </div>
</div> </div>
<!-- Password Field --> {# Password Field #}
<div> <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 Password
</label> </label>
<div class="relative"> <div class="relative">
@@ -60,36 +73,36 @@ ob_start();
id="password" id="password"
name="password" name="password"
required 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"> placeholder="Enter your password">
<button <button
type="button" type="button"
onclick="togglePassword()" 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> <i class="fas fa-eye text-sm" id="toggleIcon"></i>
</button> </button>
</div> </div>
</div> </div>
<!-- Remember Me --> {# Remember Me #}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="flex items-center cursor-pointer"> <label class="flex items-center cursor-pointer">
<input <input
type="checkbox" type="checkbox"
name="remember" name="remember"
value="1" value="1"
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">
<span class="ml-2 text-sm text-gray-600">Remember me</span> <span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Remember me</span>
</label> </label>
<a href="/forgot-password" class="text-sm text-primary hover:text-primary-dark"> <a href="/forgot-password" class="text-sm text-primary hover:text-primary-dark">
Forgot password? Forgot password?
</a> </a>
</div> </div>
<!-- CAPTCHA Widget --> {# CAPTCHA Widget #}
<?php include __DIR__ . '/captcha-widget.php'; ?> {% include 'auth/_captcha-widget.twig' %}
<!-- Submit Button --> {# Submit Button #}
<button <button
type="submit" 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"> 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> </button>
</form> </form>
<?php if ($registrationEnabled ?? false): ?> {% if registrationEnabled|default(false) %}
<!-- Sign Up Link --> {# Sign Up Link #}
<div class="text-center mt-6 pt-6 border-t border-gray-200"> <div class="text-center mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600 dark:text-gray-400">
Don't have an account? Don't have an account?
<a href="/register" class="text-primary hover:text-primary-dark font-medium"> <a href="/register" class="text-primary hover:text-primary-dark font-medium">
Create Account Create Account
</a> </a>
</p> </p>
</div> </div>
<?php endif; ?> {% endif %}
{% endblock %}
<?php {% block scripts %}
$content = ob_get_clean();
$scripts = <<<'SCRIPT'
<script> <script>
function togglePassword() { function togglePassword() {
const passwordInput = document.getElementById('password'); const passwordInput = document.getElementById('password');
@@ -129,6 +141,4 @@ $scripts = <<<'SCRIPT'
} }
} }
</script> </script>
SCRIPT; {% endblock %}
require __DIR__ . '/base-auth.php';
?>

View File

@@ -1,44 +1,47 @@
<?php {#
$title = 'Register'; # Registration Page
ob_start(); #}
?> {% extends 'auth/base-auth.twig' %}
<!-- Logo and Title --> {% set title = 'Register' %}
{% block content %}
{# Logo and Title #}
<div class="text-center mb-8"> <div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4"> <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> <i class="fas fa-user-plus text-white text-2xl"></i>
</div> </div>
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Create Account</h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-1">Create Account</h1>
<p class="text-sm text-gray-500">Join Domain Monitor today</p> <p class="text-sm text-gray-500 dark:text-gray-400">Join Domain Monitor today</p>
</div> </div>
<!-- Error/Success Alert --> {# 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="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"> <div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i> <i class="fas fa-exclamation-circle text-red-500 dark:text-red-400 mr-2"></i>
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span> <span class="text-sm text-red-700 dark:text-red-300">{{ flash.error }}</span>
</div> </div>
</div> </div>
<?php unset($_SESSION['error']); ?> {% endif %}
<?php endif; ?>
<?php if (isset($_SESSION['success'])): ?> {# Success Alert #}
<div class="mb-6 bg-green-50 border border-green-200 p-3 rounded-lg"> {% 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"> <div class="flex items-center">
<i class="fas fa-check-circle text-green-500 mr-2"></i> <i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
<span class="text-sm text-green-700"><?= htmlspecialchars($_SESSION['success']) ?></span> <span class="text-sm text-green-700 dark:text-green-300">{{ flash.success }}</span>
</div> </div>
</div> </div>
<?php unset($_SESSION['success']); ?> {% endif %}
<?php endif; ?>
<!-- Registration Form --> {# Registration Form #}
<form method="POST" action="/register" class="space-y-4"> <form method="POST" action="/register" class="space-y-4">
<?= csrf_field() ?> {{ csrf_field() }}
<!-- Full Name Field -->
{# Full Name Field #}
<div> <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 Full Name
</label> </label>
<div class="relative"> <div class="relative">
@@ -51,14 +54,14 @@ ob_start();
name="full_name" name="full_name"
required required
autofocus 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"> placeholder="Enter your full name">
</div> </div>
</div> </div>
<!-- Username Field --> {# Username Field #}
<div> <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 Username
</label> </label>
<div class="relative"> <div class="relative">
@@ -71,15 +74,15 @@ ob_start();
name="username" name="username"
required required
pattern="[a-zA-Z0-9_]+" 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"> placeholder="Choose a username">
</div> </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> </div>
<!-- Email Field --> {# Email Field #}
<div> <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 Email Address
</label> </label>
<div class="relative"> <div class="relative">
@@ -91,14 +94,14 @@ ob_start();
id="email" id="email"
name="email" name="email"
required 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"> placeholder="your.email@example.com">
</div> </div>
</div> </div>
<!-- Password Field --> {# Password Field #}
<div> <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 Password
</label> </label>
<div class="relative"> <div class="relative">
@@ -111,21 +114,21 @@ ob_start();
name="password" name="password"
required required
minlength="8" 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"> placeholder="Create a strong password">
<button <button
type="button" type="button"
onclick="togglePassword('password')" 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> <i class="fas fa-eye text-sm" id="toggleIcon-password"></i>
</button> </button>
</div> </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> </div>
<!-- Confirm Password Field --> {# Confirm Password Field #}
<div> <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 Confirm Password
</label> </label>
<div class="relative"> <div class="relative">
@@ -138,18 +141,18 @@ ob_start();
name="password_confirm" name="password_confirm"
required required
minlength="8" 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"> placeholder="Re-enter your password">
<button <button
type="button" type="button"
onclick="togglePassword('password_confirm')" 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> <i class="fas fa-eye text-sm" id="toggleIcon-password_confirm"></i>
</button> </button>
</div> </div>
</div> </div>
<!-- Terms Checkbox --> {# Terms Checkbox #}
<div class="flex items-start pt-2"> <div class="flex items-start pt-2">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input <input
@@ -157,17 +160,17 @@ ob_start();
id="terms" id="terms"
name="terms" name="terms"
required 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> </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 I agree to the Terms of Service and Privacy Policy
</label> </label>
</div> </div>
<!-- CAPTCHA Widget --> {# CAPTCHA Widget #}
<?php include __DIR__ . '/captcha-widget.php'; ?> {% include 'auth/_captcha-widget.twig' %}
<!-- Submit Button --> {# Submit Button #}
<button <button
type="submit" 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"> 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> </button>
</form> </form>
<!-- Sign In Link --> {# Sign In Link #}
<div class="text-center mt-6 pt-6 border-t border-gray-200"> <div class="text-center mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600 dark:text-gray-400">
Already have an account? Already have an account?
<a href="/login" class="text-primary hover:text-primary-dark font-medium"> <a href="/login" class="text-primary hover:text-primary-dark font-medium">
Sign In Sign In
</a> </a>
</p> </p>
</div> </div>
{% endblock %}
<?php {% block scripts %}
$content = ob_get_clean();
$scripts = <<<'SCRIPT'
<script> <script>
function togglePassword(fieldId) { function togglePassword(fieldId) {
const passwordInput = document.getElementById(fieldId); const passwordInput = document.getElementById(fieldId);
@@ -216,6 +218,4 @@ $scripts = <<<'SCRIPT'
} }
}); });
</script> </script>
SCRIPT; {% endblock %}
require __DIR__ . '/base-auth.php';
?>

View File

@@ -1,37 +1,50 @@
<?php {#
$title = 'Reset Password'; # Reset Password Page
ob_start(); #}
?> {% 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="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4"> <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> <i class="fas fa-lock-open text-white text-2xl"></i>
</div> </div>
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Reset Password</h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-1">Reset Password</h1>
<p class="text-sm text-gray-500">Enter your new password below</p> <p class="text-sm text-gray-500 dark:text-gray-400">Enter your new password below</p>
</div> </div>
<!-- Error/Success Alert --> {# Success Alert #}
<?php if (isset($_SESSION['error'])): ?> {% if flash.success is defined %}
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg"> <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"> <div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i> <i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span> <span class="text-sm text-green-700 dark:text-green-300">{{ flash.success }}</span>
</div> </div>
</div> </div>
<?php unset($_SESSION['error']); ?> {% endif %}
<?php 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"> <form method="POST" action="/reset-password" class="space-y-4">
<?= csrf_field() ?> {{ csrf_field() }}
<!-- Hidden token field -->
<input type="hidden" name="token" value="<?= htmlspecialchars($token ?? '') ?>">
<!-- Password Field --> {# Hidden token field #}
<input type="hidden" name="token" value="{{ token|default('') }}">
{# Password Field #}
<div> <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 New Password
</label> </label>
<div class="relative"> <div class="relative">
@@ -45,21 +58,21 @@ ob_start();
required required
minlength="8" minlength="8"
autofocus 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"> placeholder="Enter new password">
<button <button
type="button" type="button"
onclick="togglePassword('password')" 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> <i class="fas fa-eye text-sm" id="toggleIcon-password"></i>
</button> </button>
</div> </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> </div>
<!-- Confirm Password Field --> {# Confirm Password Field #}
<div> <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 Confirm New Password
</label> </label>
<div class="relative"> <div class="relative">
@@ -72,34 +85,34 @@ ob_start();
name="password_confirm" name="password_confirm"
required required
minlength="8" 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"> placeholder="Re-enter new password">
<button <button
type="button" type="button"
onclick="togglePassword('password_confirm')" 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> <i class="fas fa-eye text-sm" id="toggleIcon-password_confirm"></i>
</button> </button>
</div> </div>
</div> </div>
<!-- Password Strength Indicator --> {# Password Strength Indicator #}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3"> <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 mb-2"> <p class="text-xs text-blue-800 dark:text-blue-300 mb-2">
<i class="fas fa-shield-alt mr-1"></i> <i class="fas fa-shield-alt mr-1"></i>
<strong>Password Requirements:</strong> <strong>Password Requirements:</strong>
</p> </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>At least 8 characters long</li>
<li>Mix of uppercase and lowercase letters recommended</li> <li>Mix of uppercase and lowercase letters recommended</li>
<li>Include numbers and special characters for extra security</li> <li>Include numbers and special characters for extra security</li>
</ul> </ul>
</div> </div>
<!-- CAPTCHA Widget --> {# CAPTCHA Widget #}
<?php include __DIR__ . '/captcha-widget.php'; ?> {% include 'auth/_captcha-widget.twig' %}
<!-- Submit Button --> {# Submit Button #}
<button <button
type="submit" 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"> 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> </button>
</form> </form>
<!-- Back to Login Link --> {# Back to Login Link #}
<div class="text-center mt-6 pt-6 border-t border-gray-200"> <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 hover:text-gray-800"> <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> <i class="fas fa-arrow-left mr-2"></i>
Back to Login Back to Login
</a> </a>
</div> </div>
{% endblock %}
<?php {% block scripts %}
$content = ob_get_clean();
$scripts = <<<'SCRIPT'
<script> <script>
function togglePassword(fieldId) { function togglePassword(fieldId) {
const passwordInput = document.getElementById(fieldId); const passwordInput = document.getElementById(fieldId);
@@ -146,6 +158,4 @@ $scripts = <<<'SCRIPT'
} }
}); });
</script> </script>
SCRIPT; {% endblock %}
require __DIR__ . '/base-auth.php';
?>

View File

@@ -1,60 +1,63 @@
<?php {#
$title = 'Verify Email'; # Verify Email Page
ob_start(); #}
?> {% extends 'auth/base-auth.twig' %}
<?php if ($verified ?? false): ?> {% set title = 'Verify Email' %}
<!-- Success State -->
{% block content %}
{% if verified|default(false) %}
{# Success State #}
<div class="text-center"> <div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4"> <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 text-3xl"></i> <i class="fas fa-check-circle text-green-600 dark:text-green-400 text-3xl"></i>
</div> </div>
<h1 class="text-2xl font-semibold text-gray-900 mb-2">Email Verified!</h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Email Verified!</h1>
<p class="text-gray-600 mb-6">Your email address has been successfully verified.</p> <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"> <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> <i class="fas fa-sign-in-alt mr-2"></i>
Sign In to Your Account Sign In to Your Account
</a> </a>
</div> </div>
<?php elseif ($error ?? false): ?> {% elseif error|default(false) %}
<!-- Error State --> {# Error State #}
<div class="text-center"> <div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4"> <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 text-3xl"></i> <i class="fas fa-times-circle text-red-600 dark:text-red-400 text-3xl"></i>
</div> </div>
<h1 class="text-2xl font-semibold text-gray-900 mb-2">Verification Failed</h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Verification Failed</h1>
<p class="text-gray-600 mb-6"><?= htmlspecialchars($errorMessage ?? 'Invalid or expired verification link.') ?></p> <p class="text-gray-600 dark:text-gray-400 mb-6">{{ errorMessage|default('Invalid or expired verification link.') }}</p>
<div class="space-y-2"> <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"> <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> <i class="fas fa-sign-in-alt mr-2"></i>
Go to Login Go to Login
</a> </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> <i class="fas fa-redo mr-2"></i>
Resend Verification Email Resend Verification Email
</a> </a>
</div> </div>
</div> </div>
<?php else: ?> {% else %}
<!-- Pending State --> {# Pending State #}
<div class="text-center"> <div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4"> <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 text-3xl"></i> <i class="fas fa-envelope text-blue-600 dark:text-blue-400 text-3xl"></i>
</div> </div>
<h1 class="text-2xl font-semibold text-gray-900 mb-2">Check Your Email</h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Check Your Email</h1>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 dark:text-gray-400 mb-6">
We've sent a verification link to <strong><?= htmlspecialchars($email ?? 'your email') ?></strong>. 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. Please check your inbox and click the link to verify your account.
</p> </p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 text-left"> <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 mb-2"> <p class="text-sm text-blue-800 dark:text-blue-300 mb-2">
<i class="fas fa-info-circle mr-1"></i> <i class="fas fa-info-circle mr-1"></i>
<strong>Didn't receive the email?</strong> <strong>Didn't receive the email?</strong>
</p> </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>Check your spam or junk folder</li>
<li>Make sure you entered the correct email address</li> <li>Make sure you entered the correct email address</li>
<li>Wait a few minutes for the email to arrive</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> <i class="fas fa-redo mr-2"></i>
Resend Verification Email Resend Verification Email
</a> </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 Back to Login
</a> </a>
</div> </div>
</div> </div>
<?php endif; ?> {% endif %}
{% endblock %}
<?php
$content = ob_get_clean();
require __DIR__ . '/base-auth.php';
?>

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 {% extends "layout/base.twig" %}
$title = 'WHOIS Debug Tool';
$pageTitle = 'WHOIS Debug Tool';
$pageDescription = 'Test and debug WHOIS data extraction';
$pageIcon = 'fas fa-search';
ob_start();
?>
<?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 --> <!-- Search Form -->
<div class="max-w-2xl mx-auto"> <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"> <form method="GET" action="/debug/whois" class="space-y-4">
<div> <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 Domain Name
</label> </label>
<input type="text" <input type="text"
id="domain" id="domain"
name="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)" placeholder="Enter domain (e.g., google.com)"
required required
autofocus> 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. Enter a domain name without http:// or www.
</p> </p>
</div> </div>
@@ -36,14 +37,14 @@ ob_start();
</div> </div>
<!-- Info Card --> <!-- 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 items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-500 text-lg"></i> <i class="fas fa-info-circle text-blue-500 text-lg"></i>
</div> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">What is this tool?</h3> <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 leading-relaxed"> <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. 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. Use it to troubleshoot issues with domain information extraction.
</p> </p>
@@ -52,10 +53,10 @@ ob_start();
</div> </div>
</div> </div>
<?php else: ?> {% else %}
<!-- Back Button & Copy Report --> <!-- Back Button & Copy Report -->
<div class="mb-4 flex justify-between items-center"> <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> <i class="fas fa-arrow-left mr-2"></i>
Check Another Domain Check Another Domain
</a> </a>
@@ -66,19 +67,19 @@ ob_start();
</div> </div>
<!-- Domain Info Card --> <!-- 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 class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">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 mt-1"><?= htmlspecialchars($domain) ?></p> <p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">{{ domain }}</p>
</div> </div>
<div> <div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">WHOIS 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 mt-1"><?= htmlspecialchars($server) ?></p> <p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">{{ server }}</p>
</div> </div>
<div> <div>
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">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 mt-1"><?= htmlspecialchars($tld) ?></p> <p class="text-sm font-semibold text-gray-900 dark:text-white mt-1">{{ tld }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -86,53 +87,53 @@ ob_start();
<!-- Main Content Grid --> <!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Parsed Data --> <!-- Parsed Data -->
<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">
<div class="px-5 py-3 border-b border-gray-200 bg-green-50"> <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 flex items-center"> <h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-check-circle text-green-600 mr-2 text-sm"></i> <i class="fas fa-check-circle text-green-600 dark:text-green-400 mr-2 text-sm"></i>
Extracted Data (What We Save) Extracted Data (What We Save)
</h2> </h2>
</div> </div>
<div class="p-5"> <div class="p-5">
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between py-2 border-b border-gray-100"> <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">Domain</span> <span class="text-xs font-medium text-gray-600 dark:text-slate-400">Domain</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['domain'] ?? 'N/A') ?></span> <span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.domain ?? 'N/A' }}</span>
</div> </div>
<div class="flex justify-between py-2 border-b border-gray-100"> <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">Registrar</span> <span class="text-xs font-medium text-gray-600 dark:text-slate-400">Registrar</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['registrar'] ?? 'N/A') ?></span> <span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.registrar ?? 'N/A' }}</span>
</div> </div>
<div class="flex justify-between py-2 border-b border-gray-100"> <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">Expiration Date</span> <span class="text-xs font-medium text-gray-600 dark:text-slate-400">Expiration Date</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['expiration_date'] ?? 'N/A') ?></span> <span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.expiration_date ?? 'N/A' }}</span>
</div> </div>
<div class="flex justify-between py-2 border-b border-gray-100"> <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">Creation Date</span> <span class="text-xs font-medium text-gray-600 dark:text-slate-400">Creation Date</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['creation_date'] ?? 'N/A') ?></span> <span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.creation_date ?? 'N/A' }}</span>
</div> </div>
<div class="flex justify-between py-2 border-b border-gray-100"> <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">Updated Date</span> <span class="text-xs font-medium text-gray-600 dark:text-slate-400">Updated Date</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['updated_date'] ?? 'N/A') ?></span> <span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.updated_date ?? 'N/A' }}</span>
</div> </div>
<div class="flex justify-between py-2 border-b border-gray-100"> <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">Registrar URL</span> <span class="text-xs font-medium text-gray-600 dark:text-slate-400">Registrar URL</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['registrar_url'] ?? 'N/A') ?></span> <span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.registrar_url ?? 'N/A' }}</span>
</div> </div>
<div class="flex justify-between py-2 border-b border-gray-100"> <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">Abuse Email</span> <span class="text-xs font-medium text-gray-600 dark:text-slate-400">Abuse Email</span>
<span class="text-xs text-gray-900 font-mono"><?= htmlspecialchars($info['abuse_email'] ?? 'N/A') ?></span> <span class="text-xs text-gray-900 dark:text-white font-mono">{{ info.abuse_email ?? 'N/A' }}</span>
</div> </div>
<div class="py-2"> <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"> <div class="space-y-1">
<?php if (!empty($info['nameservers'])): ?> {% if info.nameservers is not empty %}
<?php foreach ($info['nameservers'] as $ns): ?> {% for ns in info.nameservers %}
<div class="text-xs text-gray-900 font-mono bg-gray-50 px-2 py-1 rounded"><?= htmlspecialchars($ns) ?></div> <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>
<?php endforeach; ?> {% endfor %}
<?php else: ?> {% else %}
<span class="text-xs text-gray-400">N/A</span> <span class="text-xs text-gray-400 dark:text-slate-500">N/A</span>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -140,30 +141,30 @@ ob_start();
</div> </div>
<!-- Key-Value Pairs --> <!-- Key-Value Pairs -->
<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">
<div class="px-5 py-3 border-b border-gray-200 bg-blue-50"> <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 flex items-center"> <h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-table text-blue-600 mr-2 text-sm"></i> <i class="fas fa-table text-blue-600 dark:text-blue-400 mr-2 text-sm"></i>
All Key-Value Pairs All Key-Value Pairs
</h2> </h2>
</div> </div>
<div class="overflow-y-auto" style="max-height: 500px;"> <div class="overflow-y-auto" style="max-height: 500px;">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
<thead class="bg-gray-50 sticky top-0"> <thead class="bg-gray-50 dark:bg-slate-700 sticky top-0">
<tr> <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 dark:text-slate-400 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">Value</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-100"> <tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-100 dark:divide-slate-700">
<?php foreach ($parsedData as $item): ?> {% for item in parsedData %}
<?php if (!empty($item['value'])): ?> {% if item.value is not empty %}
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
<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 font-medium text-gray-700 dark:text-slate-300">{{ item.key }}</td>
<td class="px-4 py-2 text-xs text-gray-900 font-mono"><?= htmlspecialchars($item['value']) ?></td> <td class="px-4 py-2 text-xs text-gray-900 dark:text-white font-mono">{{ item.value }}</td>
</tr> </tr>
<?php endif; ?> {% endif %}
<?php endforeach; ?> {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -171,27 +172,27 @@ ob_start();
</div> </div>
<!-- Raw Response --> <!-- Raw Response -->
<div class="mt-4 bg-white rounded-lg border border-gray-200 overflow-hidden"> <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 bg-gray-50"> <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 flex items-center"> <h2 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-code text-gray-600 mr-2 text-sm"></i> <i class="fas fa-file-code text-gray-600 dark:text-slate-400 mr-2 text-sm"></i>
Raw WHOIS Response Raw WHOIS Response
</h2> </h2>
</div> </div>
<div class="p-5"> <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>
</div> </div>
<!-- Hidden data for JS --> <!-- Hidden data for JS -->
<script id="debug-data" type="application/json"> <script id="debug-data" type="application/json">
{ {
"domain": <?= json_encode($domain) ?>, "domain": {{ domain|json_encode|raw }},
"tld": <?= json_encode($tld) ?>, "tld": {{ tld|json_encode|raw }},
"server": <?= json_encode($server) ?>, "server": {{ server|json_encode|raw }},
"extractedData": <?= json_encode($info) ?>, "extractedData": {{ info|json_encode|raw }},
"rawResponse": <?= json_encode($response) ?>, "rawResponse": {{ response|json_encode|raw }},
"parsedKeyValuePairs": <?= json_encode($parsedData) ?> "parsedKeyValuePairs": {{ parsedData|json_encode|raw }}
} }
</script> </script>
@@ -231,33 +232,26 @@ ob_start();
report += data.rawResponse; report += data.rawResponse;
report += `\n\n=== END OF REPORT ===`; report += `\n\n=== END OF REPORT ===`;
// Copy to clipboard with fallback
copyToClipboard(report, button); copyToClipboard(report, button);
} }
// Robust clipboard copy function with fallback
function copyToClipboard(text, button) { function copyToClipboard(text, button) {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
showCopySuccess(button); showCopySuccess(button);
}).catch(err => { }).catch(err => {
console.error('Modern clipboard API failed:', err); console.error('Modern clipboard API failed:', err);
// Fallback to legacy method
fallbackCopyTextToClipboard(text, button); fallbackCopyTextToClipboard(text, button);
}); });
} else { } else {
// Use fallback for non-HTTPS or older browsers
fallbackCopyTextToClipboard(text, button); fallbackCopyTextToClipboard(text, button);
} }
} }
function fallbackCopyTextToClipboard(text, button) { function fallbackCopyTextToClipboard(text, button) {
// Create a temporary textarea
const textArea = document.createElement('textarea'); const textArea = document.createElement('textarea');
textArea.value = text; textArea.value = text;
// Make it invisible but accessible
textArea.style.position = 'fixed'; textArea.style.position = 'fixed';
textArea.style.top = '0'; textArea.style.top = '0';
textArea.style.left = '0'; textArea.style.left = '0';
@@ -308,10 +302,6 @@ ob_start();
} }
</script> </script>
<?php endif; ?> {% endif %}
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>
{% endblock %}

View File

@@ -1,60 +1,61 @@
<?php {% extends 'layout/base.twig' %}
$title = 'Bulk Add Domains';
$pageTitle = 'Bulk Add Domains'; {% set title = 'Bulk Add Domains' %}
$pageDescription = 'Add multiple domains at once'; {% set pageTitle = 'Bulk Add Domains' %}
$pageIcon = 'fas fa-layer-group'; {% set pageDescription = 'Add multiple domains at once' %}
ob_start(); {% set pageIcon = 'fas fa-layer-group' %}
?>
{% block content %}
<!-- Main Container --> <!-- Main Container -->
<div class="max-w-4xl mx-auto"> <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 --> <!-- Tabs -->
<div class="flex border-b border-gray-200 bg-gray-50"> <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 transition-colors"> <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 <i class="fas fa-keyboard mr-2"></i>Paste Domains
</button> </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 <i class="fas fa-file-upload mr-2"></i>Import from File
</button> </button>
</div> </div>
<!-- Tab 1: Paste Domains (existing) --> <!-- Tab 1: Paste Domains -->
<div id="panel-paste" class="p-6"> <div id="panel-paste" class="p-6">
<form method="POST" action="/domains/bulk-add" class="space-y-5"> <form method="POST" action="/domains/bulk-add" class="space-y-5">
<?= csrf_field() ?> {{ csrf_field() }}
<!-- Domains Textarea --> <!-- Domains Textarea -->
<div> <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 * Domain Names *
</label> </label>
<textarea <textarea
id="domains" id="domains"
name="domains" name="domains"
rows="10" 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;..." placeholder="example.com&#10;google.com&#10;github.com&#10;..."
required required
autofocus></textarea> 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. Enter one domain per line. Domains without http:// or www.
</p> </p>
</div> </div>
<!-- Tags --> <!-- Tags -->
<div> <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 Tags
<span class="text-gray-400 font-normal">(Optional)</span> <span class="text-gray-400 dark:text-slate-500 font-normal">(Optional)</span>
</label> </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"> <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" <input type="text"
id="tags-input" 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..." placeholder="Type any tag and press Enter or comma..."
onkeydown="handleTagInput(event)"> 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"> <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=""> <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> <i class="fas fa-info-circle mr-1"></i>
All imported domains will be tagged with these tags. All imported domains will be tagged with these tags.
</p> </p>
<div class="mt-2"> <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"> <div class="flex flex-wrap gap-1.5">
<?php foreach ($availableTags as $tag): ?> {% for tag in availableTags %}
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')" <button type="button" onclick="addTag('{{ 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"> 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> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
<?= htmlspecialchars($tag['name']) ?> {{ tag.name }}
</button> </button>
<?php endforeach; ?> {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<!-- Notification Group --> <!-- Notification Group -->
<div> <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) Notification Group (Optional)
</label> </label>
<select id="notification_group_id" <select id="notification_group_id"
name="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> <option value="">-- No Group (No notifications) --</option>
<?php foreach ($groups as $group): ?> {% for group in groups %}
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option> <option value="{{ group.id }}">{{ group.name }}</option>
<?php endforeach; ?> {% endfor %}
</select> </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 Assign all domains to this notification group
</p> </p>
</div> </div>
@@ -109,7 +110,7 @@ ob_start();
Add All Domains Add All Domains
</button> </button>
<a href="/domains" <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> <i class="fas fa-times mr-2"></i>
Cancel Cancel
</a> </a>
@@ -120,30 +121,30 @@ ob_start();
<!-- Tab 2: Import from File --> <!-- Tab 2: Import from File -->
<div id="panel-import" class="hidden p-6"> <div id="panel-import" class="hidden p-6">
<form method="POST" action="/domains/import" enctype="multipart/form-data" class="space-y-5" id="domainImportForm"> <form method="POST" action="/domains/import" enctype="multipart/form-data" class="space-y-5" id="domainImportForm">
<?= csrf_field() ?> {{ csrf_field() }}
<!-- Drag & Drop Zone --> <!-- Drag & Drop Zone -->
<div> <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 * Select File *
</label> </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" <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"> class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<div id="domainDropzoneContent"> <div id="domainDropzoneContent">
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-3"></i> <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 font-medium">Drag & drop your file here</p> <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 my-1.5">or</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"> <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 <i class="fas fa-folder-open mr-1.5"></i>Browse Files
</span> </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>
<div id="domainDropzoneFile" class="hidden"> <div id="domainDropzoneFile" class="hidden">
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i> <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-sm font-medium text-gray-700 dark:text-slate-300" id="domainFileName"></p>
<p class="text-xs text-gray-400" id="domainFileSize"></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 font-medium"> <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 <i class="fas fa-trash-alt mr-1"></i>Remove
</button> </button>
</div> </div>
@@ -151,31 +152,31 @@ ob_start();
</div> </div>
<!-- Expected Format Info --> <!-- Expected Format Info -->
<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">
<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-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 mb-2">CSV columns or JSON fields:</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"> <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 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 rounded text-xs border border-gray-200 text-gray-600">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">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 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 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-gray-200 dark:border-slate-700 text-gray-600 dark:text-slate-400">notification_group</code>
</div> </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> </div>
<!-- Fallback Notification Group --> <!-- Fallback Notification Group -->
<div> <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 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> </label>
<select id="import_notification_group_id" <select id="import_notification_group_id"
name="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> <option value="">-- No Group (No notifications) --</option>
<?php foreach ($groups as $group): ?> {% for group in groups %}
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option> <option value="{{ group.id }}">{{ group.name }}</option>
<?php endforeach; ?> {% endfor %}
</select> </select>
</div> </div>
@@ -187,7 +188,7 @@ ob_start();
Import Domains Import Domains
</button> </button>
<a href="/domains" <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> <i class="fas fa-times mr-2"></i>
Cancel Cancel
</a> </a>
@@ -198,7 +199,7 @@ ob_start();
<!-- Info Cards --> <!-- Info Cards -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4"> <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 items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center"> <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> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3> <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 leading-relaxed"> <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 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. for each domain automatically. This may take a few moments depending on how many domains you're adding.
</p> </p>
@@ -215,7 +216,7 @@ ob_start();
</div> </div>
</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 items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center"> <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> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">Important Notes</h3> <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">Important Notes</h3>
<ul class="text-xs text-gray-600 space-y-1"> <ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
<li class="flex items-start"> <li class="flex items-start">
<i class="fas fa-circle text-orange-500 mt-1 mr-2" style="font-size: 6px;"></i> <i class="fas fa-circle text-orange-500 mt-1 mr-2" style="font-size: 6px;"></i>
<span>Duplicate domains will be skipped</span> <span>Duplicate domains will be skipped</span>
@@ -244,8 +245,10 @@ ob_start();
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %}
<script> <script>
// Tab switching
function switchTab(tab) { function switchTab(tab) {
document.getElementById('panel-paste').classList.toggle('hidden', tab !== 'paste'); document.getElementById('panel-paste').classList.toggle('hidden', tab !== 'paste');
document.getElementById('panel-import').classList.toggle('hidden', tab !== 'import'); document.getElementById('panel-import').classList.toggle('hidden', tab !== 'import');
@@ -254,18 +257,17 @@ function switchTab(tab) {
const importTab = document.getElementById('tab-import'); const importTab = document.getElementById('tab-import');
[pasteTab, importTab].forEach(btn => { [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 active = tab === 'paste' ? pasteTab : importTab;
const inactive = tab === 'paste' ? importTab : pasteTab; const inactive = tab === 'paste' ? importTab : pasteTab;
active.classList.add('border-primary', 'text-primary', 'bg-white'); active.classList.add('border-primary', 'text-primary', 'bg-white', 'dark:bg-slate-800');
inactive.classList.add('border-transparent', 'text-gray-500'); inactive.classList.add('border-transparent', 'text-gray-500', 'dark:text-slate-400');
} }
let tags = []; let tags = [];
// Available tags with their colors from the database const availableTags = {{ availableTags|json_encode|raw }};
const availableTags = <?= json_encode($availableTags) ?>;
const tagColors = {}; const tagColors = {};
availableTags.forEach(tag => { availableTags.forEach(tag => {
tagColors[tag.name] = tag.color; tagColors[tag.name] = tag.color;
@@ -274,12 +276,10 @@ availableTags.forEach(tag => {
function addTag(tagName) { function addTag(tagName) {
tagName = tagName.trim().toLowerCase(); tagName = tagName.trim().toLowerCase();
// Validate tag (alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(tagName)) { if (!/^[a-z0-9-]+$/.test(tagName)) {
return; return;
} }
// Check if tag already exists
if (tags.includes(tagName)) { if (tags.includes(tagName)) {
return; return;
} }
@@ -288,7 +288,6 @@ function addTag(tagName) {
updateTagsDisplay(); updateTagsDisplay();
updateHiddenInput(); updateHiddenInput();
// Clear input
document.getElementById('tags-input').value = ''; document.getElementById('tags-input').value = '';
} }
@@ -303,7 +302,7 @@ function updateTagsDisplay() {
display.innerHTML = ''; display.innerHTML = '';
if (tags.length === 0) { 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; return;
} }
@@ -338,17 +337,14 @@ function addTagFromInput() {
const value = input.value.trim(); const value = input.value.trim();
if (value) { if (value) {
// Handle multiple tags separated by commas
const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t); const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t);
newTags.forEach(tag => addTag(tag)); newTags.forEach(tag => addTag(tag));
input.value = ''; input.value = '';
} }
} }
// Initialize display
updateTagsDisplay(); updateTagsDisplay();
// --- Domain Import drag-and-drop & loading ---
(function() { (function() {
const dropzone = document.getElementById('domainDropzone'); const dropzone = document.getElementById('domainDropzone');
const fileInput = document.getElementById('domainFileInput'); const fileInput = document.getElementById('domainFileInput');
@@ -371,7 +367,7 @@ updateTagsDisplay();
fileSize.textContent = formatSize(file.size); fileSize.textContent = formatSize(file.size);
content.classList.add('hidden'); content.classList.add('hidden');
fileInfo.classList.remove('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'); dropzone.classList.add('border-primary', 'bg-primary/5');
} }
@@ -379,7 +375,7 @@ updateTagsDisplay();
fileInput.value = ''; fileInput.value = '';
content.classList.remove('hidden'); content.classList.remove('hidden');
fileInfo.classList.add('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'); dropzone.classList.remove('border-primary', 'bg-primary/5');
} }
@@ -397,7 +393,7 @@ updateTagsDisplay();
dropzone.addEventListener(evt, function(e) { dropzone.addEventListener(evt, function(e) {
e.preventDefault(); e.preventDefault();
dropzone.classList.add('border-primary', 'bg-primary/5'); 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(); e.preventDefault();
if (!fileInput.files.length) { if (!fileInput.files.length) {
dropzone.classList.remove('border-primary', 'bg-primary/5'); 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> </script>
{% endblock %}
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -1,57 +1,58 @@
<?php {% extends 'layout/base.twig' %}
$title = 'Add New Domain';
$pageTitle = 'Add New Domain'; {% set title = 'Add New Domain' %}
$pageDescription = 'Start monitoring a new domain'; {% set pageTitle = 'Add New Domain' %}
$pageIcon = 'fas fa-plus-circle'; {% set pageDescription = 'Start monitoring a new domain' %}
ob_start(); {% set pageIcon = 'fas fa-plus-circle' %}
?>
{% block content %}
<!-- Main Form --> <!-- Main Form -->
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl 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">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 flex items-center"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-globe text-gray-400 mr-2 text-sm"></i> <i class="fas fa-globe text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
Domain Information Domain Information
</h2> </h2>
</div> </div>
<div class="p-6"> <div class="p-6">
<form method="POST" action="/domains/store" class="space-y-5"> <form method="POST" action="/domains/store" class="space-y-5">
<?= csrf_field() ?> {{ csrf_field() }}
<!-- Domain Name --> <!-- Domain Name -->
<div> <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 * Domain Name *
</label> </label>
<input type="text" <input type="text"
id="domain_name" id="domain_name"
name="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" placeholder="example.com"
required required
autofocus> 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:// Enter the domain name without http:// or https://
</p> </p>
</div> </div>
<!-- Tags --> <!-- Tags -->
<div> <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 Tags
<span class="text-gray-400 font-normal">(Optional)</span> <span class="text-gray-400 dark:text-slate-500 font-normal">(Optional)</span>
</label> </label>
<!-- Tag Display Area --> <!-- 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 --> <!-- Tag Input -->
<div class="relative"> <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" <input type="text"
id="tags-input" 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..." placeholder="Type any tag and press Enter or comma..."
onkeydown="handleTagInput(event)"> 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"> <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 --> <!-- Hidden input to store tags for form submission -->
<input type="hidden" id="tags" name="tags" value=""> <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> <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> </p>
<!-- Available Tags --> <!-- Available Tags -->
<div class="mt-2"> <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"> <div class="flex flex-wrap gap-1.5">
<?php foreach ($availableTags as $tag): ?> {% for tag in availableTags %}
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')" <button type="button" onclick="addTag('{{ 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"> 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> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
<?= htmlspecialchars($tag['name']) ?> {{ tag.name }}
</button> </button>
<?php endforeach; ?> {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<!-- Notification Group --> <!-- Notification Group -->
<div> <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 Notification Group
</label> </label>
<select id="notification_group_id" <select id="notification_group_id"
name="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> <option value="">-- No Group (No notifications) --</option>
<?php foreach ($groups as $group): ?> {% for group in groups %}
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option> <option value="{{ group.id }}">{{ group.name }}</option>
<?php endforeach; ?> {% endfor %}
</select> </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 Optional: Assign to a notification group to receive expiry alerts
</p> </p>
</div> </div>
@@ -108,7 +109,7 @@ ob_start();
Add Domain Add Domain
</button> </button>
<a href="/domains" <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> <i class="fas fa-times mr-2"></i>
Cancel Cancel
</a> </a>
@@ -120,7 +121,7 @@ ob_start();
<!-- Info Cards --> <!-- Info Cards -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- How it works --> <!-- 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 items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center"> <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> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3> <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 leading-relaxed"> <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 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. expiration date, registrar, nameservers, and other important details. This may take a few seconds.
</p> </p>
@@ -138,7 +139,7 @@ ob_start();
</div> </div>
<!-- What we track --> <!-- 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 items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center"> <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> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">What We Track</h3> <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 space-y-1"> <ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
<li class="flex items-center"> <li class="flex items-center">
<i class="fas fa-circle text-green-500" style="font-size: 6px;"></i> <i class="fas fa-circle text-green-500" style="font-size: 6px;"></i>
<span class="ml-2">Domain expiration date</span> <span class="ml-2">Domain expiration date</span>
@@ -171,11 +172,13 @@ ob_start();
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %}
<script> <script>
let tags = []; let tags = [];
// Available tags with their colors from the database const availableTags = {{ availableTags|json_encode|raw }};
const availableTags = <?= json_encode($availableTags) ?>;
const tagColors = {}; const tagColors = {};
availableTags.forEach(tag => { availableTags.forEach(tag => {
tagColors[tag.name] = tag.color; tagColors[tag.name] = tag.color;
@@ -184,12 +187,10 @@ availableTags.forEach(tag => {
function addTag(tagName) { function addTag(tagName) {
tagName = tagName.trim().toLowerCase(); tagName = tagName.trim().toLowerCase();
// Validate tag (alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(tagName)) { if (!/^[a-z0-9-]+$/.test(tagName)) {
return; return;
} }
// Check if tag already exists
if (tags.includes(tagName)) { if (tags.includes(tagName)) {
return; return;
} }
@@ -198,7 +199,6 @@ function addTag(tagName) {
updateTagsDisplay(); updateTagsDisplay();
updateHiddenInput(); updateHiddenInput();
// Clear input
document.getElementById('tags-input').value = ''; document.getElementById('tags-input').value = '';
} }
@@ -213,7 +213,7 @@ function updateTagsDisplay() {
display.innerHTML = ''; display.innerHTML = '';
if (tags.length === 0) { 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; return;
} }
@@ -248,18 +248,12 @@ function addTagFromInput() {
const value = input.value.trim(); const value = input.value.trim();
if (value) { if (value) {
// Handle multiple tags separated by commas
const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t); const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t);
newTags.forEach(tag => addTag(tag)); newTags.forEach(tag => addTag(tag));
input.value = ''; input.value = '';
} }
} }
// Initialize display
updateTagsDisplay(); updateTagsDisplay();
</script> </script>
{% endblock %}
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -1,60 +1,61 @@
<?php {% extends 'layout/base.twig' %}
$title = 'Edit Domain';
$pageTitle = 'Edit Domain'; {% set title = 'Edit Domain' %}
$pageDescription = htmlspecialchars($domain['domain_name']); {% set pageTitle = 'Edit Domain' %}
$pageIcon = 'fas fa-edit'; {% set pageDescription = domain.domain_name %}
ob_start(); {% set pageIcon = 'fas fa-edit' %}
?>
{% block content %}
<!-- Main Form --> <!-- Main Form -->
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl 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">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 flex items-center"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-cog text-gray-400 mr-2 text-sm"></i> <i class="fas fa-cog text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
Domain Settings Domain Settings
</h2> </h2>
</div> </div>
<div class="p-6"> <div class="p-6">
<form method="POST" action="/domains/<?= $domain['id'] ?>/update" class="space-y-5"> <form method="POST" action="/domains/{{ domain.id }}/update" class="space-y-5">
<?= csrf_field() ?> {{ csrf_field() }}
<!-- Domain Name (Read-only) --> <!-- Domain Name (Read-only) -->
<div> <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 Domain Name
</label> </label>
<div class="relative"> <div class="relative">
<input type="text" <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" 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="<?= htmlspecialchars($domain['domain_name']) ?>" value="{{ domain.domain_name }}"
disabled> disabled>
<div class="absolute right-3 top-1/2 transform -translate-y-1/2"> <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>
</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 Domain name cannot be changed after creation
</p> </p>
</div> </div>
<!-- Tags --> <!-- Tags -->
<div> <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 Tags
<span class="text-gray-400 font-normal">(Optional)</span> <span class="text-gray-400 dark:text-slate-500 font-normal">(Optional)</span>
</label> </label>
<!-- Tag Display Area --> <!-- 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 --> <!-- Tag Input -->
<div class="relative"> <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" <input type="text"
id="tags-input" 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..." placeholder="Type any tag and press Enter or comma..."
onkeydown="handleTagInput(event)"> 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"> <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> </div>
<!-- Hidden input to store tags for form submission --> <!-- 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> <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> </p>
<!-- Available Tags --> <!-- Available Tags -->
<div class="mt-2"> <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"> <div class="flex flex-wrap gap-1.5">
<?php foreach ($availableTags as $tag): ?> {% for tag in availableTags %}
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')" <button type="button" onclick="addTag('{{ 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"> 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> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
<?= htmlspecialchars($tag['name']) ?> {{ tag.name }}
</button> </button>
<?php endforeach; ?> {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<!-- Notification Group --> <!-- Notification Group -->
<div> <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 Notification Group
</label> </label>
<select id="notification_group_id" <select id="notification_group_id"
name="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> <option value="">-- No Group (No notifications) --</option>
<?php foreach ($groups as $group): ?> {% for group in groups %}
<option value="<?= $group['id'] ?>" <option value="{{ group.id }}"
<?= $domain['notification_group_id'] == $group['id'] ? 'selected' : '' ?>> {{ domain.notification_group_id == group.id ? 'selected' : '' }}>
<?= htmlspecialchars($group['name']) ?> {{ group.name }}
</option> </option>
<?php endforeach; ?> {% endfor %}
</select> </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 Change the notification group or remove it to stop receiving alerts
</p> </p>
</div> </div>
<!-- Manual Expiration Date --> <!-- Manual Expiration Date -->
<div> <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 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> </label>
<div class="relative"> <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" <input type="date"
id="manual_expiration_date" id="manual_expiration_date"
name="manual_expiration_date" name="manual_expiration_date"
value="<?= $domain['expiration_date'] ? date('Y-m-d', strtotime($domain['expiration_date'])) : '' ?>" 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 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-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> </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> <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). 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. This will be used for expiration notifications and status calculations.
</p> </p>
<?php if ($domain['expiration_date']): ?> {% if domain.expiration_date %}
<p class="mt-1 text-xs text-green-600"> <p class="mt-1 text-xs text-green-600 dark:text-green-400">
<i class="fas fa-check-circle mr-1"></i> <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> </p>
<?php else: ?> {% else %}
<p class="mt-1 text-xs text-amber-600"> <p class="mt-1 text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i> <i class="fas fa-exclamation-triangle mr-1"></i>
No expiration date available from WHOIS/RDAP. Consider setting a manual date. No expiration date available from WHOIS/RDAP. Consider setting a manual date.
</p> </p>
<?php endif; ?> {% endif %}
</div> </div>
<!-- Active Monitoring --> <!-- 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"> <label class="flex items-center cursor-pointer">
<input type="checkbox" <input type="checkbox"
name="is_active" name="is_active"
<?= $domain['is_active'] ? 'checked' : '' ?> {{ domain.is_active ? 'checked' : '' }}
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary cursor-pointer"> class="w-4 h-4 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
<div class="ml-3"> <div class="ml-3">
<span class="text-sm font-medium text-gray-900">Enable Active Monitoring</span> <span class="text-sm font-medium text-gray-900 dark:text-white">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> <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> </div>
</label> </label>
</div> </div>
@@ -159,8 +160,8 @@ ob_start();
<i class="fas fa-save mr-2"></i> <i class="fas fa-save mr-2"></i>
Update Domain Update Domain
</button> </button>
<a href="<?= htmlspecialchars($referrer ?? '/domains/' . $domain['id']) ?>" <a href="{{ referrer|default('/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"> 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> <i class="fas fa-times mr-2"></i>
Cancel Cancel
</a> </a>
@@ -171,37 +172,38 @@ ob_start();
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3"> <div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<a href="/domains/<?= $domain['id'] ?>" <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"> 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 mr-2 text-sm"></i> <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">View Details</span> <span class="text-sm font-medium text-gray-700 dark:text-slate-300">View Details</span>
</a> </a>
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="m-0"> <form method="POST" action="/domains/{{ domain.id }}/refresh" class="m-0">
<?= csrf_field() ?> {{ csrf_field() }}
<button type="submit" <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"> 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 mr-2 text-sm"></i> <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">Refresh WHOIS</span> <span class="text-sm font-medium text-gray-700 dark:text-slate-300">Refresh WHOIS</span>
</button> </button>
</form> </form>
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" onsubmit="return confirm('Delete this domain permanently?')" class="m-0"> <form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain permanently?')" class="m-0">
<?= csrf_field() ?> {{ csrf_field() }}
<button type="submit" <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"> 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 mr-2 text-sm"></i> <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">Delete Domain</span> <span class="text-sm font-medium text-gray-700 dark:text-slate-300">Delete Domain</span>
</button> </button>
</form> </form>
</div> </div>
</div> </div>
{% endblock %}
{% block scripts %}
<script> <script>
// Initialize tags from existing domain data const existingTags = {{ domain.tags|default('')|json_encode|raw }};
const existingTags = '<?= htmlspecialchars($domain['tags'] ?? '') ?>';
let tags = existingTags ? existingTags.split(',').map(t => t.trim().toLowerCase()).filter(t => t) : []; let tags = existingTags ? existingTags.split(',').map(t => t.trim().toLowerCase()).filter(t => t) : [];
// Available tags with their colors from the database const availableTags = {{ availableTags|json_encode|raw }};
const availableTags = <?= json_encode($availableTags) ?>;
const tagColors = {}; const tagColors = {};
availableTags.forEach(tag => { availableTags.forEach(tag => {
tagColors[tag.name] = tag.color; tagColors[tag.name] = tag.color;
@@ -210,12 +212,10 @@ availableTags.forEach(tag => {
function addTag(tagName) { function addTag(tagName) {
tagName = tagName.trim().toLowerCase(); tagName = tagName.trim().toLowerCase();
// Validate tag (alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(tagName)) { if (!/^[a-z0-9-]+$/.test(tagName)) {
return; return;
} }
// Check if tag already exists
if (tags.includes(tagName)) { if (tags.includes(tagName)) {
return; return;
} }
@@ -224,7 +224,6 @@ function addTag(tagName) {
updateTagsDisplay(); updateTagsDisplay();
updateHiddenInput(); updateHiddenInput();
// Clear input
document.getElementById('tags-input').value = ''; document.getElementById('tags-input').value = '';
} }
@@ -239,7 +238,7 @@ function updateTagsDisplay() {
display.innerHTML = ''; display.innerHTML = '';
if (tags.length === 0) { 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; return;
} }
@@ -274,18 +273,12 @@ function addTagFromInput() {
const value = input.value.trim(); const value = input.value.trim();
if (value) { if (value) {
// Handle multiple tags separated by commas
const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t); const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t);
newTags.forEach(tag => addTag(tag)); newTags.forEach(tag => addTag(tag));
input.value = ''; input.value = '';
} }
} }
// Initialize display
updateTagsDisplay(); updateTagsDisplay();
</script> </script>
{% endblock %}
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found</title> <title>404 - Page Not Found</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script> <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" /> <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> <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"> <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="max-w-2xl w-full">
<div class="bg-white rounded-2xl shadow-2xl p-12 text-center"> <div class="bg-white rounded-2xl shadow-2xl p-12 text-center">
<!-- 404 Icon -->
<div class="mb-8"> <div class="mb-8">
<i class="fas fa-exclamation-triangle text-yellow-500 text-8xl mb-4 animate-pulse"></i> <i class="fas fa-exclamation-triangle text-yellow-500 text-8xl mb-4 animate-pulse"></i>
</div> </div>
<!-- Error Message -->
<h1 class="text-9xl font-bold text-gray-800 mb-4">404</h1> <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> <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"> <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. Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
</p> </p>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center"> <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"> <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> <i class="fas fa-home mr-2"></i>
@@ -53,7 +47,6 @@
</button> </button>
</div> </div>
<!-- Helpful Links -->
<div class="mt-12 pt-8 border-t border-gray-200"> <div class="mt-12 pt-8 border-t border-gray-200">
<p class="text-sm text-gray-500 mb-4">Quick Links:</p> <p class="text-sm text-gray-500 mb-4">Quick Links:</p>
<div class="flex flex-wrap justify-center gap-4"> <div class="flex flex-wrap justify-center gap-4">
@@ -73,11 +66,10 @@
</div> </div>
</div> </div>
<!-- Footer -->
<div class="text-center mt-8"> <div class="text-center mt-8">
<p class="text-gray-600"> <p class="text-gray-600">
<i class="fas fa-globe text-primary"></i> <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> </p>
</div> </div>
</div> </div>

View File

@@ -5,10 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 - Internal Server Error</title> <title>500 - Internal Server Error</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script> <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" /> <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> <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"> <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="max-w-2xl w-full">
<div class="bg-white rounded-2xl shadow-2xl p-12 text-center"> <div class="bg-white rounded-2xl shadow-2xl p-12 text-center">
<!-- Error Icon -->
<div class="mb-8"> <div class="mb-8">
<i class="fas fa-exclamation-circle text-red-500 text-8xl mb-4"></i> <i class="fas fa-exclamation-circle text-red-500 text-8xl mb-4"></i>
</div> </div>
<!-- Error Message -->
<h1 class="text-9xl font-bold text-gray-800 mb-4">500</h1> <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> <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"> <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. Oops! Something went wrong on our end. We're working to fix the issue.
</p> </p>
<!-- Error Reference ID --> {% if error_id is defined and error_id %}
<?php if (!empty($error_id)): ?>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-5 mb-8"> <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 items-center justify-center space-x-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -52,7 +46,7 @@
<p class="text-sm font-medium text-gray-700 mb-1">Error Reference ID:</p> <p class="text-sm font-medium text-gray-700 mb-1">Error Reference ID:</p>
<div class="flex items-center space-x-2"> <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"> <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> </code>
<button onclick="copyErrorId()" <button onclick="copyErrorId()"
class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
@@ -67,9 +61,8 @@
</div> </div>
</div> </div>
</div> </div>
<?php endif; ?> {% endif %}
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center"> <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"> <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> <i class="fas fa-home mr-2"></i>
@@ -85,7 +78,6 @@
</button> </button>
</div> </div>
<!-- Helpful Links -->
<div class="mt-12 pt-8 border-t border-gray-200"> <div class="mt-12 pt-8 border-t border-gray-200">
<p class="text-sm text-gray-500 mb-4">Quick Links:</p> <p class="text-sm text-gray-500 mb-4">Quick Links:</p>
<div class="flex flex-wrap justify-center gap-4"> <div class="flex flex-wrap justify-center gap-4">
@@ -101,41 +93,39 @@
<i class="fas fa-search mr-1"></i> <i class="fas fa-search mr-1"></i>
WHOIS Lookup WHOIS Lookup
</a> </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"> <a href="/settings" class="text-primary hover:text-primary-dark transition-colors duration-150">
<i class="fas fa-cog mr-1"></i> <i class="fas fa-cog mr-1"></i>
Settings Settings
</a> </a>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
<!-- Support Info -->
<div class="mt-8 bg-gray-50 rounded-lg p-4"> <div class="mt-8 bg-gray-50 rounded-lg p-4">
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
<i class="fas fa-life-ring text-primary mr-1"></i> <i class="fas fa-life-ring text-primary mr-1"></i>
If this problem persists, please contact your system administrator 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. and provide the error reference ID above.
<?php else: ?> {% else %}
. .
<?php endif; ?> {% endif %}
</p> </p>
</div> </div>
</div> </div>
<!-- Footer -->
<div class="text-center mt-8"> <div class="text-center mt-8">
<p class="text-gray-600"> <p class="text-gray-600">
<i class="fas fa-globe text-primary"></i> <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> </p>
</div> </div>
</div> </div>
<script> <script>
function copyErrorId() { function copyErrorId() {
const errorId = '<?= htmlspecialchars($error_id ?? '') ?>'; const errorId = {{ (error_id|default(''))|json_encode|raw }};
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(errorId).then(() => { navigator.clipboard.writeText(errorId).then(() => {
@@ -184,4 +174,3 @@
</script> </script>
</body> </body>
</html> </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> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <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" /> <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> <script>
@@ -28,28 +25,15 @@
</script> </script>
<style> <style>
body { body { background-color: #f8f9fa; }
background-color: #f8f9fa;
}
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); } from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.animate-fade-in { animation: fadeIn 0.4s ease-out; }
.animate-fade-in { .code-block { background-color: #1e1e1e; color: #d4d4d4; }
animation: fadeIn 0.4s ease-out; .line-number { color: #858585; user-select: none; }
}
.code-block {
background-color: #1e1e1e;
color: #d4d4d4;
}
.line-number {
color: #858585;
user-select: none;
}
</style> </style>
</head> </head>
<body class="min-h-screen p-6"> <body class="min-h-screen p-6">
@@ -84,18 +68,16 @@
<!-- Primary Error Card --> <!-- Primary Error Card -->
<div class="bg-white rounded-lg shadow-sm border-l-4 border-red-500 mb-6 animate-fade-in"> <div class="bg-white rounded-lg shadow-sm border-l-4 border-red-500 mb-6 animate-fade-in">
<div class="p-6"> <div class="p-6">
<!-- Error Header -->
<div class="flex items-start mb-6"> <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"> <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> <i class="fas fa-exclamation-triangle text-red-600 text-2xl"></i>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h2 class="text-2xl font-bold text-gray-900 mb-2"> <h2 class="text-2xl font-bold text-gray-900 mb-2">
<?= htmlspecialchars($error_type ?? 'Error') ?> {{ error_type|default('Error') }}
</h2> </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"> <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"> <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> <i class="fas fa-map-marker-alt text-red-500 mr-2 text-xs"></i>
@@ -105,12 +87,12 @@
<div> <div>
<span class="text-xs font-medium text-gray-600">File:</span> <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"> <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> </code>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<span class="text-xs font-medium text-gray-600 mr-2">Line:</span> <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> </div>
</div> </div>
@@ -119,67 +101,63 @@
<!-- Quick Info Grid --> <!-- Quick Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <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="bg-blue-50 rounded-lg border border-blue-200 p-4">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide">Error ID</h4> <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"> class="text-primary hover:text-primary-dark" title="Copy Error ID">
<i class="fas fa-copy text-xs"></i> <i class="fas fa-copy text-xs"></i>
</button> </button>
</div> </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"> <p class="text-xs text-gray-600 mt-2">
<i class="fas fa-info-circle mr-1"></i> <i class="fas fa-info-circle mr-1"></i>
Use for bug reports Use for bug reports
</p> </p>
</div> </div>
<!-- Request Info -->
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4"> <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> <h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">Request</h4>
<div class="space-y-1"> <div class="space-y-1">
<p class="text-sm"> <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> </p>
<code class="text-xs text-gray-600 font-mono block truncate" title="<?= htmlspecialchars($request_uri ?? '/') ?>"> <code class="text-xs text-gray-600 font-mono block truncate" title="{{ request_uri|default('/') }}">
<?= htmlspecialchars($request_uri ?? '/') ?> {{ request_uri|default('/') }}
</code> </code>
</div> </div>
</div> </div>
<!-- User Context -->
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4"> <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> <h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">User</h4>
<?php if ($user_info): ?> {% if user_info %}
<p class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($user_info['username']) ?></p> <p class="text-sm font-semibold text-gray-900">{{ user_info.username }}</p>
<p class="text-xs text-gray-600"> <p class="text-xs text-gray-600">
<i class="fas fa-user mr-1"></i> <i class="fas fa-user mr-1"></i>
<?= htmlspecialchars($user_info['role']) ?> {{ user_info.role }}
</p> </p>
<?php else: ?> {% else %}
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
<i class="fas fa-user-slash mr-1"></i> <i class="fas fa-user-slash mr-1"></i>
Guest (Not logged in) Guest (Not logged in)
</p> </p>
<?php endif; ?> {% endif %}
</div> </div>
<!-- System Info -->
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4"> <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> <h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">System</h4>
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs text-gray-600"> <p class="text-xs text-gray-600">
<i class="fas fa-code mr-1"></i> <i class="fas fa-code mr-1"></i>
PHP <?= htmlspecialchars($php_version ?? PHP_VERSION) ?> PHP {{ php_version|default('unknown') }}
</p> </p>
<p class="text-xs text-gray-600"> <p class="text-xs text-gray-600">
<i class="fas fa-memory mr-1"></i> <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>
<p class="text-xs text-gray-600"> <p class="text-xs text-gray-600">
<i class="fas fa-clock mr-1"></i> <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> </p>
</div> </div>
</div> </div>
@@ -193,8 +171,7 @@
<!-- Left Column --> <!-- Left Column -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Stack Trace --> {% if stack_trace is defined and stack_trace %}
<?php if (!empty($stack_trace)): ?>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in"> <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="px-6 py-4 border-b border-gray-200 bg-gray-50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -211,24 +188,20 @@
</div> </div>
<div class="p-6"> <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"> <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 {% for line in stack_trace|split("\n") %}
$traceLines = explode("\n", $stack_trace); {% if line|trim %}
foreach ($traceLines as $index => $line) { <div class="flex font-mono text-sm">
if (trim($line)) { <span class="line-number mr-4 text-right" style="min-width: 2rem">{{ '%02d'|format(loop.index0) }}</span>
echo '<div class="flex font-mono text-sm">'; <span class="flex-1 text-green-400">{{ line }}</span>
echo '<span class="line-number mr-4 text-right" style="min-width: 2rem">' . str_pad($index, 2, '0', STR_PAD_LEFT) . '</span>'; </div>
echo '<span class="flex-1 text-green-400">' . htmlspecialchars($line) . '</span>'; {% endif %}
echo '</div>'; {% endfor %}
}
}
?>
</div> </div>
</div> </div>
</div> </div>
<?php endif; ?> {% endif %}
<!-- Request Data --> {% if request_data is defined and request_data %}
<?php if (!empty($request_data)): ?>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in"> <div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
<button onclick="toggleSection('request-data')" <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"> 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> <i class="fas fa-paper-plane text-blue-500 mr-2 text-sm"></i>
Request Data Request Data
<span class="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded font-medium"> <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>
</span> </span>
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="request-data-chevron"></i> <i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="request-data-chevron"></i>
@@ -245,20 +218,24 @@
</button> </button>
<div id="request-data" class="hidden p-6"> <div id="request-data" class="hidden p-6">
<div class="space-y-3"> <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"> <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"> <span class="text-xs font-semibold text-gray-700 uppercase tracking-wide block mb-1">
<?= htmlspecialchars($key) ?> {{ key }}
</span> </span>
<code class="text-sm text-gray-800 font-mono block break-all"> <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> </code>
</div> </div>
<?php endforeach; ?> {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
@@ -277,24 +254,24 @@
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
<div class="flex items-center justify-between py-2 border-b border-gray-100"> <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-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>
<div class="py-2 border-b border-gray-100"> <div class="py-2 border-b border-gray-100">
<span class="font-medium text-gray-600 block mb-1">URI</span> <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"> <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> </code>
</div> </div>
<div class="py-2 border-b border-gray-100"> <div class="py-2 border-b border-gray-100">
<span class="font-medium text-gray-600 block mb-1">IP Address</span> <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"> <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> </code>
</div> </div>
<div class="py-2"> <div class="py-2">
<span class="font-medium text-gray-600 block mb-1">User Agent</span> <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"> <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> </code>
</div> </div>
</div> </div>
@@ -313,26 +290,25 @@
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
<div class="flex items-center justify-between py-2 border-b border-gray-100"> <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-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>
<div class="flex items-center justify-between py-2 border-b border-gray-100"> <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-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>
<div class="flex items-center justify-between py-2 border-b border-gray-100"> <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-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>
<div class="flex items-center justify-between py-2"> <div class="flex items-center justify-between py-2">
<span class="font-medium text-gray-600">Timestamp</span> <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>
</div> </div>
</div> </div>
<!-- Session Data --> {% if session_data is defined and session_data %}
<?php if (!empty($session_data)): ?>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in"> <div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
<button onclick="toggleSection('session-data')" <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"> 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> <i class="fas fa-user-lock text-orange-500 mr-2 text-sm"></i>
Session Data Session Data
<span class="ml-2 text-xs bg-orange-100 text-orange-800 px-2 py-1 rounded font-medium"> <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>
</span> </span>
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="session-data-chevron"></i> <i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="session-data-chevron"></i>
@@ -357,20 +333,24 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-100"> <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"> <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"> <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> </td>
</tr> </tr>
<?php endforeach; ?> {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
@@ -409,12 +389,11 @@
<div class="text-center text-sm text-gray-500"> <div class="text-center text-sm text-gray-500">
<p> <p>
<i class="fas fa-globe text-primary mr-1"></i> <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> </p>
</div> </div>
</div> </div>
<!-- JavaScript -->
<script> <script>
function toggleSection(sectionId) { function toggleSection(sectionId) {
const section = document.getElementById(sectionId); const section = document.getElementById(sectionId);
@@ -422,14 +401,10 @@
if (section.classList.contains('hidden')) { if (section.classList.contains('hidden')) {
section.classList.remove('hidden'); section.classList.remove('hidden');
if (chevron) { if (chevron) chevron.style.transform = 'rotate(180deg)';
chevron.style.transform = 'rotate(180deg)';
}
} else { } else {
section.classList.add('hidden'); section.classList.add('hidden');
if (chevron) { if (chevron) chevron.style.transform = 'rotate(0deg)';
chevron.style.transform = 'rotate(0deg)';
}
} }
} }
@@ -452,14 +427,12 @@
textArea.style.left = '-999999px'; textArea.style.left = '-999999px';
document.body.appendChild(textArea); document.body.appendChild(textArea);
textArea.select(); textArea.select();
try { try {
document.execCommand('copy'); document.execCommand('copy');
showCopySuccess(); showCopySuccess();
} catch (err) { } catch (err) {
console.error('Copy failed:', err); console.error('Copy failed:', err);
} }
document.body.removeChild(textArea); document.body.removeChild(textArea);
} }
@@ -467,34 +440,29 @@
const stackTraceElement = document.getElementById('stack-trace'); const stackTraceElement = document.getElementById('stack-trace');
const lines = stackTraceElement.querySelectorAll('div'); const lines = stackTraceElement.querySelectorAll('div');
let stackText = ''; let stackText = '';
lines.forEach(line => { lines.forEach(line => {
const textSpan = line.querySelector('span:last-child'); const textSpan = line.querySelector('span:last-child');
if (textSpan) { if (textSpan) stackText += textSpan.textContent + '\n';
stackText += textSpan.textContent + '\n';
}
}); });
copyToClipboard(stackText.trim()); copyToClipboard(stackText.trim());
} }
function copyErrorReport() { function copyErrorReport() {
const errorType = <?= json_encode($error_type ?? 'Error') ?>; const errorType = {{ (error_type|default('Error'))|json_encode|raw }};
const errorMessage = <?= json_encode($error_message ?? 'Unknown error') ?>; const errorMessage = {{ (error_message|default('Unknown error'))|json_encode|raw }};
const errorFile = <?= json_encode($error_file ?? 'Unknown') ?>; const errorFile = {{ (error_file|default('Unknown'))|json_encode|raw }};
const errorLine = <?= json_encode($error_line ?? '?') ?>; const errorLine = {{ (error_line|default('?'))|json_encode|raw }};
const errorId = <?= json_encode($error_id ?? 'N/A') ?>; const errorId = {{ (error_id|default('N/A'))|json_encode|raw }};
const phpVersion = <?= json_encode($php_version ?? PHP_VERSION) ?>; const phpVersion = {{ (php_version|default('unknown'))|json_encode|raw }};
const requestMethod = <?= json_encode($request_method ?? 'GET') ?>; const requestMethod = {{ (request_method|default('GET'))|json_encode|raw }};
const requestUri = <?= json_encode($request_uri ?? '/') ?>; const requestUri = {{ (request_uri|default('/'))|json_encode|raw }};
const userAgent = <?= json_encode($user_agent ?? 'Unknown') ?>; const userAgent = {{ (user_agent|default('Unknown'))|json_encode|raw }};
const ipAddress = <?= json_encode($ip_address ?? 'Unknown') ?>; const ipAddress = {{ (ip_address|default('Unknown'))|json_encode|raw }};
const timestamp = <?= json_encode(date('Y-m-d H:i:s', strtotime($occurred_at ?? 'now'))) ?>; 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)'; const userText = userInfo ? `${userInfo.username} (${userInfo.role}, ID: ${userInfo.id})` : 'Guest (Not logged in)';
// Get stack trace
const stackTraceElement = document.getElementById('stack-trace'); const stackTraceElement = document.getElementById('stack-trace');
let stackTrace = 'Not available'; let stackTrace = 'Not available';
if (stackTraceElement) { if (stackTraceElement) {
@@ -502,9 +470,7 @@
let stackText = ''; let stackText = '';
lines.forEach(line => { lines.forEach(line => {
const textSpan = line.querySelector('span:last-child'); const textSpan = line.querySelector('span:last-child');
if (textSpan) { if (textSpan) stackText += textSpan.textContent + '\n';
stackText += textSpan.textContent + '\n';
}
}); });
stackTrace = stackText.trim(); stackTrace = stackText.trim();
} }
@@ -532,8 +498,8 @@ USER CONTEXT:
SYSTEM INFORMATION: SYSTEM INFORMATION:
- PHP Version: ${phpVersion} - PHP Version: ${phpVersion}
- Memory Usage: ${<?= round(($memory_usage ?? memory_get_usage(true)) / 1024 / 1024, 2) ?>}MB - Memory Usage: {{ memory_usage_mb|default(0) }}MB
- Peak Memory: ${<?= round(memory_get_peak_usage(true) / 1024 / 1024, 2) ?>}MB - Peak Memory: {{ peak_memory_mb|default(0) }}MB
STACK TRACE: STACK TRACE:
${stackTrace} ${stackTrace}
@@ -562,4 +528,3 @@ Please include this report when reporting bugs.`;
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,57 +1,57 @@
<?php {% extends 'layout/base.twig' %}
$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();
?>
<!-- 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="max-w-3xl 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">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 flex items-center"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-bell text-gray-400 mr-2 text-sm"></i> <i class="fas fa-bell text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
Group Information Group Information
</h2> </h2>
</div> </div>
<div class="p-6"> <div class="p-6">
<form method="POST" action="/groups/store" class="space-y-5"> <form method="POST" action="/groups/store" class="space-y-5">
<?= csrf_field() ?> {{ csrf_field() }}
<!-- Group Name --> {# Group Name #}
<div> <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 * Group Name *
</label> </label>
<input type="text" <input type="text"
id="name" id="name"
name="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" 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" placeholder="e.g., Production Alerts, Team Notifications"
required required
autofocus> 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 Choose a descriptive name for this notification group
</p> </p>
</div> </div>
<!-- Description --> {# Description #}
<div> <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) Description (Optional)
</label> </label>
<textarea id="description" <textarea id="description"
name="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" 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" rows="4"
placeholder="Add details about this notification group, its purpose, or who should be notified..."></textarea> 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 Optional: Add notes to help identify this group's purpose
</p> </p>
</div> </div>
<!-- Action Buttons --> {# Action Buttons #}
<div class="flex flex-col sm:flex-row gap-3 pt-3"> <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"> 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">
@@ -59,7 +59,7 @@ ob_start();
Create Group Create Group
</button> </button>
<a href="/groups" <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"> 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> <i class="fas fa-times mr-2"></i>
Cancel Cancel
</a> </a>
@@ -68,8 +68,8 @@ ob_start();
</div> </div>
</div> </div>
<!-- Info Section --> {# 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/30 rounded-lg p-4">
<div class="flex items-start"> <div class="flex items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center"> <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> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">Next Steps</h3> <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">Next Steps</h3>
<ul class="text-xs text-gray-600 space-y-1"> <ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
<li class="flex items-center"> <li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i> <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> <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> </div>
</div> </div>
{% endblock %}
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -1,48 +1,52 @@
<?php {% extends 'layout/base.twig' %}
$title = 'Edit Notification Group';
$pageTitle = 'Edit Notification Group';
$pageDescription = htmlspecialchars($group['name']);
$pageIcon = 'fas fa-edit';
ob_start();
?>
{% set title = 'Edit Notification Group' %}
{% set pageTitle = 'Edit Notification Group' %}
{% set pageDescription = group.name %}
{% set pageIcon = 'fas fa-edit' %}
{% set channelIcons = { email: 'fa-envelope', telegram: 'fa-telegram', discord: 'fa-discord', slack: 'fa-slack', mattermost: 'fa-comments', pushover: 'fa-mobile-alt', webhook: 'fa-link' } %}
{% set channelIconPrefixes = { email: 'fas', telegram: 'fab', discord: 'fab', slack: 'fab', mattermost: 'fas', pushover: 'fas', webhook: 'fas' } %}
{% set channelColors = { email: 'blue', telegram: 'blue', discord: 'indigo', slack: 'teal', mattermost: 'green', pushover: 'red', webhook: 'purple' } %}
{% block content %}
<div class="max-w-7xl mx-auto space-y-4"> <div class="max-w-7xl mx-auto space-y-4">
<!-- Group Details Form --> {# Group Details Form #}
<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">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 flex items-center"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-info-circle text-gray-400 mr-2 text-sm"></i> <i class="fas fa-info-circle text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
Group Details Group Details
</h2> </h2>
</div> </div>
<div class="p-6"> <div class="p-6">
<form method="POST" action="/groups/<?= $group['id'] ?>/update" class="space-y-5"> <form method="POST" action="/groups/{{ group.id }}/update" class="space-y-5">
<?= csrf_field() ?> {{ csrf_field() }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- Group Name --> {# Group Name #}
<div> <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 * Group Name *
</label> </label>
<input type="text" <input type="text"
id="name" id="name"
name="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" 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"
value="<?= htmlspecialchars($group['name']) ?>" value="{{ group.name }}"
required> required>
</div> </div>
<!-- Description --> {# Description #}
<div> <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) Description (Optional)
</label> </label>
<textarea id="description" <textarea id="description"
name="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" 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="3"><?= htmlspecialchars($group['description'] ?? '') ?></textarea> rows="3">{{ group.description|default('') }}</textarea>
</div> </div>
</div> </div>
@@ -57,88 +61,78 @@ ob_start();
</div> </div>
</div> </div>
<!-- Notification Channels --> {# Notification Channels #}
<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">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 flex items-center"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-plug text-gray-400 mr-2 text-sm"></i> <i class="fas fa-plug text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
Notification Channels Notification Channels
</h2> </h2>
</div> </div>
<div class="p-6"> <div class="p-6">
<?php if (empty($group['channels'])): ?> {% if group.channels is empty %}
<div class="text-center py-10"> <div class="text-center py-10">
<i class="fas fa-plug text-gray-300 text-5xl mb-3"></i> <i class="fas fa-plug text-gray-300 dark:text-slate-600 text-5xl mb-3"></i>
<p class="text-gray-500">No channels configured yet</p> <p class="text-gray-500 dark:text-slate-400">No channels configured yet</p>
<p class="text-sm text-gray-400 mt-1">Add your first channel below to start receiving notifications</p> <p class="text-sm text-gray-400 dark:text-slate-500 mt-1">Add your first channel below to start receiving notifications</p>
</div> </div>
<?php else: ?> {% else %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<?php foreach ($group['channels'] as $channel): {% for channel in group.channels %}
$config = json_decode($channel['channel_config'], true); {% set config = channel.channel_config|from_json %}
$icons = ['email' => 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack', 'mattermost' => 'fa-comments', 'pushover' => 'fa-mobile-alt', 'webhook' => 'fa-link']; {% set icon = channelIcons[channel.channel_type]|default('fa-bell') %}
$iconClasses = ['email' => 'fas', 'telegram' => 'fab', 'discord' => 'fab', 'slack' => 'fab', 'mattermost' => 'fas', 'pushover' => 'fas', 'webhook' => 'fas']; {% set iconPrefix = channelIconPrefixes[channel.channel_type]|default('fas') %}
$colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal', 'mattermost' => 'green', 'pushover' => 'red', 'webhook' => 'purple']; {% set color = channelColors[channel.channel_type]|default('gray') %}
$icon = $icons[$channel['channel_type']] ?? 'fa-bell'; {% set formatLabels = { generic: 'Generic', google_chat: 'Google Chat', simple_text: 'Simple Text' } %}
$iconClass = $iconClasses[$channel['channel_type']] ?? 'fas'; <div class="bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-lg p-6 hover:shadow-md transition-shadow duration-200">
$color = $colors[$channel['channel_type']] ?? 'gray';
?>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow duration-200">
<div class="flex items-start justify-between mb-4"> <div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 bg-<?= $color ?>-100 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-{{ color }}-100 dark:bg-{{ color }}-500/20 rounded-lg flex items-center justify-center">
<?php if ($channel['channel_type'] === 'mattermost'): ?> {% if channel.channel_type == 'mattermost' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 500 500" class="text-<?= $color ?>-600"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 500 500" class="text-{{ color }}-600">
<path fill="currentColor" d="M 250.05,34.00 C 251.95,34.04 253.85,34.11 255.65,34.20 255.65,34.20 225.86,69.71 225.86,69.71 225.79,69.72 225.71,69.74 225.63,69.75 149.26,84.10 98.22,146.50 98.22,222.97 98.22,264.53 121.29,313.47 157.97,342.07 186.58,364.39 222.26,378.97 259.18,378.97 352.58,378.97 419.33,310.36 419.33,222.97 419.33,188.06 403.34,150.20 377.57,122.21 377.57,122.21 375.94,74.82 375.94,74.82 430.39,113.97 465.89,177.84 466.00,249.99 466.00,250.00 466.00,250.00 466.00,250.00 466.00,369.29 369.30,466.00 250.00,466.00 130.71,466.00 34.00,369.29 34.00,250.00 34.00,130.71 130.71,34.00 250.00,34.00 250.00,34.00 250.05,34.00 250.05,34.00 Z M 314.15,54.29 C 314.81,54.25 315.47,54.32 316.11,54.54 319.12,55.54 319.96,58.11 320.04,60.99 320.04,60.99 323.88,207.87 323.88,207.87 324.64,236.53 306.72,276.31 263.49,276.43 232.52,276.51 199.81,255.60 199.81,216.30 199.82,201.57 205.42,185.04 219.06,168.19 219.06,168.19 309.09,57.01 309.09,57.01 310.24,55.59 312.17,54.43 314.15,54.29 314.15,54.29 314.15,54.29 314.15,54.29 Z" /> <path fill="currentColor" d="M 250.05,34.00 C 251.95,34.04 253.85,34.11 255.65,34.20 255.65,34.20 225.86,69.71 225.86,69.71 225.79,69.72 225.71,69.74 225.63,69.75 149.26,84.10 98.22,146.50 98.22,222.97 98.22,264.53 121.29,313.47 157.97,342.07 186.58,364.39 222.26,378.97 259.18,378.97 352.58,378.97 419.33,310.36 419.33,222.97 419.33,188.06 403.34,150.20 377.57,122.21 377.57,122.21 375.94,74.82 375.94,74.82 430.39,113.97 465.89,177.84 466.00,249.99 466.00,250.00 466.00,250.00 466.00,250.00 466.00,369.29 369.30,466.00 250.00,466.00 130.71,466.00 34.00,369.29 34.00,250.00 34.00,130.71 130.71,34.00 250.00,34.00 250.00,34.00 250.05,34.00 250.05,34.00 Z M 314.15,54.29 C 314.81,54.25 315.47,54.32 316.11,54.54 319.12,55.54 319.96,58.11 320.04,60.99 320.04,60.99 323.88,207.87 323.88,207.87 324.64,236.53 306.72,276.31 263.49,276.43 232.52,276.51 199.81,255.60 199.81,216.30 199.82,201.57 205.42,185.04 219.06,168.19 219.06,168.19 309.09,57.01 309.09,57.01 310.24,55.59 312.17,54.43 314.15,54.29 314.15,54.29 314.15,54.29 314.15,54.29 Z" />
</svg> </svg>
<?php else: ?> {% else %}
<i class="<?= $iconClass ?> <?= $icon ?> text-<?= $color ?>-600 text-xl"></i> <i class="{{ iconPrefix }} {{ icon }} text-{{ color }}-600 text-xl"></i>
<?php endif; ?> {% endif %}
</div> </div>
<span class="px-3 py-1 rounded-full text-xs font-semibold <?= $channel['is_active'] ? 'bg-green-100 text-green-800' : 'bg-gray-200 text-gray-600' ?>"> <span class="px-3 py-1 rounded-full text-xs font-semibold {{ channel.is_active ? 'bg-green-100 dark:bg-green-500/20 text-green-800 dark:text-green-400' : 'bg-gray-200 dark:bg-slate-600 text-gray-600 dark:text-slate-400' }}">
<?= $channel['is_active'] ? 'Active' : 'Disabled' ?> {{ channel.is_active ? 'Active' : 'Disabled' }}
</span> </span>
</div> </div>
<h3 class="font-semibold text-gray-800 mb-2"><?= ucfirst($channel['channel_type']) ?></h3> <h3 class="font-semibold text-gray-800 dark:text-slate-200 mb-2">{{ channel.channel_type|capitalize }}</h3>
<p class="text-sm text-gray-600 mb-4 truncate"> <p class="text-sm text-gray-600 dark:text-slate-400 mb-4 truncate">
<?php {% if channel.channel_type == 'email' %}
if ($channel['channel_type'] === 'email') { {{ config.email|default('No email') }}
echo htmlspecialchars($config['email'] ?? 'No email'); {% elseif channel.channel_type == 'telegram' %}
} elseif ($channel['channel_type'] === 'telegram') { Chat: {{ config.chat_id|default('N/A') }}
echo "Chat: " . htmlspecialchars($config['chat_id'] ?? 'N/A'); {% elseif channel.channel_type == 'pushover' %}
} elseif ($channel['channel_type'] === 'pushover') { User: {{ (config.user_key|default('N/A'))|slice(0, 10) }}...
echo "User: " . substr(htmlspecialchars($config['user_key'] ?? 'N/A'), 0, 10) . "..."; {% elseif channel.channel_type == 'webhook' %}
} elseif ($channel['channel_type'] === 'webhook') { {% set format = config.format|default('generic') %}
$formatLabels = [ Format: {{ formatLabels[format]|default(format|capitalize) }}
'generic' => 'Generic', {% else %}
'google_chat' => 'Google Chat', Webhook configured
'simple_text' => 'Simple Text' {% endif %}
];
$format = $config['format'] ?? 'generic';
echo "Format: " . ($formatLabels[$format] ?? ucfirst($format));
} else {
echo "Webhook configured";
}
?>
</p> </p>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick="testChannel('<?= $channel['channel_type'] ?>', <?= htmlspecialchars(json_encode($config)) ?>)" <button onclick="testChannel('{{ channel.channel_type }}', {{ config|json_encode|e('html_attr') }})"
class="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded text-center text-sm hover:bg-blue-100 transition-colors duration-150"> class="flex-1 px-3 py-2 bg-blue-50 dark:bg-blue-500/10 text-blue-700 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors duration-150">
<i class="fas fa-paper-plane mr-1"></i> <i class="fas fa-paper-plane mr-1"></i>
Test Test
</button> </button>
<form method="POST" action="/groups/<?= $group['id'] ?>/channels/<?= $channel['id'] ?>/toggle" class="flex-1"> <form method="POST" action="/groups/{{ group.id }}/channels/{{ channel.id }}/toggle" class="flex-1">
<?= csrf_field() ?> {{ csrf_field() }}
<button type="submit" <button type="submit"
class="w-full px-3 py-2 bg-yellow-50 text-yellow-700 rounded text-center text-sm hover:bg-yellow-100 transition-colors duration-150"> class="w-full px-3 py-2 bg-yellow-50 dark:bg-yellow-500/10 text-yellow-700 dark:text-yellow-400 rounded text-center text-sm hover:bg-yellow-100 dark:hover:bg-yellow-500/20 transition-colors duration-150">
<i class="fas fa-<?= $channel['is_active'] ? 'pause' : 'play' ?> mr-1"></i> <i class="fas fa-{{ channel.is_active ? 'pause' : 'play' }} mr-1"></i>
<?= $channel['is_active'] ? 'Disable' : 'Enable' ?> {{ channel.is_active ? 'Disable' : 'Enable' }}
</button> </button>
</form> </form>
<form method="POST" action="/groups/<?= $group['id'] ?>/channels/<?= $channel['id'] ?>/delete" class="flex-1"> <form method="POST" action="/groups/{{ group.id }}/channels/{{ channel.id }}/delete" class="flex-1">
<?= csrf_field() ?> {{ csrf_field() }}
<button type="submit" <button type="submit"
class="w-full px-3 py-2 bg-red-50 text-red-700 rounded text-center text-sm hover:bg-red-100 transition-colors duration-150" class="w-full px-3 py-2 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-400 rounded text-center text-sm hover:bg-red-100 dark:hover:bg-red-500/20 transition-colors duration-150"
onclick="return confirm('Delete this channel?')"> onclick="return confirm('Delete this channel?')">
<i class="fas fa-trash mr-1"></i> <i class="fas fa-trash mr-1"></i>
Delete Delete
@@ -146,27 +140,27 @@ ob_start();
</form> </form>
</div> </div>
</div> </div>
<?php endforeach; ?> {% endfor %}
</div> </div>
<?php endif; ?> {% endif %}
<!-- Add Channel Form --> {# Add Channel Form #}
<div class="bg-gray-50 rounded-lg p-5 border border-gray-200"> <div class="bg-gray-50 dark:bg-slate-700 rounded-lg p-5 border border-gray-200 dark:border-slate-600">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center"> <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<i class="fas fa-plus-circle text-gray-400 mr-2 text-sm"></i> <i class="fas fa-plus-circle text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
Add New Channel Add New Channel
</h3> </h3>
<form method="POST" action="/groups/<?= $group['id'] ?>/channels" id="channelForm" class="space-y-5"> <form method="POST" action="/groups/{{ group.id }}/channels" id="channelForm" class="space-y-5">
<?= csrf_field() ?> {{ csrf_field() }}
<input type="hidden" name="group_id" value="<?= $group['id'] ?>"> <input type="hidden" name="group_id" value="{{ group.id }}">
<!-- Channel Type --> {# Channel Type #}
<div> <div>
<label for="channel_type" class="block text-sm font-medium text-gray-700 mb-1.5">Channel Type</label> <label for="channel_type" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Channel Type</label>
<select id="channel_type" <select id="channel_type"
name="channel_type" name="channel_type"
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"
onchange="toggleChannelFields()"> onchange="toggleChannelFields()">
<option value="">-- Select Channel Type --</option> <option value="">-- Select Channel Type --</option>
<option value="email">Email</option> <option value="email">Email</option>
@@ -179,158 +173,158 @@ ob_start();
</select> </select>
</div> </div>
<!-- Email Fields --> {# Email Fields #}
<div id="email_fields" class="hidden space-y-4"> <div id="email_fields" class="hidden space-y-4">
<div> <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 Email Address
</label> </label>
<input type="email" <input type="email"
id="email" id="email"
name="email" name="email"
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="user@example.com"> placeholder="user@example.com">
</div> </div>
</div> </div>
<!-- Telegram Fields --> {# Telegram Fields #}
<div id="telegram_fields" class="hidden space-y-4"> <div id="telegram_fields" class="hidden space-y-4">
<div> <div>
<label for="bot_token" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="bot_token" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
Bot Token Bot Token
</label> </label>
<input type="text" <input type="text"
id="bot_token" id="bot_token"
name="bot_token" name="bot_token"
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="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"> placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
<p class="mt-1.5 text-xs text-gray-500"> <p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
Get from @BotFather on Telegram Get from @BotFather on Telegram
</p> </p>
</div> </div>
<div> <div>
<label for="chat_id" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="chat_id" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
Chat ID Chat ID
</label> </label>
<input type="text" <input type="text"
id="chat_id" id="chat_id"
name="chat_id" name="chat_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 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="123456789"> placeholder="123456789">
<p class="mt-1.5 text-xs text-gray-500"> <p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
Use @userinfobot to get your chat ID Use @userinfobot to get your chat ID
</p> </p>
</div> </div>
</div> </div>
<!-- Discord Fields --> {# Discord Fields #}
<div id="discord_fields" class="hidden space-y-4"> <div id="discord_fields" class="hidden space-y-4">
<div> <div>
<label for="discord_webhook" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="discord_webhook" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
Webhook URL <span class="text-red-500">*</span> Webhook URL <span class="text-red-500">*</span>
</label> </label>
<input type="text" <input type="text"
id="discord_webhook" id="discord_webhook"
name="discord_webhook_url" name="discord_webhook_url"
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 focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
placeholder="https://discord.com/api/webhooks/1234567890/abcdefg..." placeholder="https://discord.com/api/webhooks/1234567890/abcdefg..."
autocomplete="off"> autocomplete="off">
<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 text-blue-500 mr-1"></i> <i class="fas fa-info-circle text-blue-500 mr-1"></i>
Paste the complete webhook URL from Discord Server Settings Integrations Webhooks Paste the complete webhook URL from Discord Server Settings &rarr; Integrations &rarr; Webhooks
</p> </p>
</div> </div>
</div> </div>
<!-- Slack Fields --> {# Slack Fields #}
<div id="slack_fields" class="hidden space-y-4"> <div id="slack_fields" class="hidden space-y-4">
<div> <div>
<label for="slack_webhook" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="slack_webhook" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
Webhook URL Webhook URL
</label> </label>
<input type="text" <input type="text"
id="slack_webhook" id="slack_webhook"
name="slack_webhook_url" name="slack_webhook_url"
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 focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
placeholder="https://hooks.slack.com/services/..." placeholder="https://hooks.slack.com/services/..."
autocomplete="off"> autocomplete="off">
<p class="mt-1.5 text-xs text-gray-500"> <p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
Create in Slack App Settings Incoming Webhooks Create in Slack App Settings &rarr; Incoming Webhooks
</p> </p>
</div> </div>
</div> </div>
<!-- Mattermost Fields --> {# Mattermost Fields #}
<div id="mattermost_fields" class="hidden space-y-4"> <div id="mattermost_fields" class="hidden space-y-4">
<div> <div>
<label for="mattermost_webhook" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="mattermost_webhook" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
Webhook URL Webhook URL
</label> </label>
<input type="text" <input type="text"
id="mattermost_webhook" id="mattermost_webhook"
name="mattermost_webhook_url" name="mattermost_webhook_url"
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 focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
placeholder="https://your-mattermost.com/hooks/..." placeholder="https://your-mattermost.com/hooks/..."
autocomplete="off"> autocomplete="off">
<p class="mt-1.5 text-xs text-gray-500"> <p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
Create in Mattermost Integrations Incoming Webhooks Create in Mattermost &rarr; Integrations &rarr; Incoming Webhooks
</p> </p>
</div> </div>
</div> </div>
<!-- Pushover Fields --> {# Pushover Fields #}
<div id="pushover_fields" class="hidden space-y-4"> <div id="pushover_fields" class="hidden space-y-4">
<div> <div>
<label for="pushover_api_token" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="pushover_api_token" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
API Token (Application Key) <span class="text-red-500">*</span> API Token (Application Key) <span class="text-red-500">*</span>
</label> </label>
<input type="text" <input type="text"
id="pushover_api_token" id="pushover_api_token"
name="pushover_api_token" name="pushover_api_token"
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 focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi" placeholder="azGDORePK8gMaC0QOYAMyEEuzJnyUi"
autocomplete="off"> autocomplete="off">
<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 text-blue-500 mr-1"></i> <i class="fas fa-info-circle text-blue-500 mr-1"></i>
Create an application at <a href="https://pushover.net/apps/build" target="_blank" class="text-blue-600 hover:underline">pushover.net/apps/build</a> Create an application at <a href="https://pushover.net/apps/build" target="_blank" class="text-blue-600 hover:underline">pushover.net/apps/build</a>
</p> </p>
</div> </div>
<div> <div>
<label for="pushover_user_key" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="pushover_user_key" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
User Key <span class="text-red-500">*</span> User Key <span class="text-red-500">*</span>
</label> </label>
<input type="text" <input type="text"
id="pushover_user_key" id="pushover_user_key"
name="pushover_user_key" name="pushover_user_key"
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 focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG"
autocomplete="off"> autocomplete="off">
<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 text-blue-500 mr-1"></i> <i class="fas fa-info-circle text-blue-500 mr-1"></i>
Find your user key on your <a href="https://pushover.net/" target="_blank" class="text-blue-600 hover:underline">Pushover dashboard</a> Find your user key on your <a href="https://pushover.net/" target="_blank" class="text-blue-600 hover:underline">Pushover dashboard</a>
</p> </p>
</div> </div>
<div> <div>
<label for="pushover_device" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="pushover_device" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
Device Name (Optional) Device Name (Optional)
</label> </label>
<input type="text" <input type="text"
id="pushover_device" id="pushover_device"
name="pushover_device" name="pushover_device"
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="Leave empty for all devices" placeholder="Leave empty for all devices"
autocomplete="off"> autocomplete="off">
<p class="mt-1.5 text-xs text-gray-500"> <p class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
Specify a device name to send to specific device only (e.g., "iPhone", "Desktop") Specify a device name to send to specific device only (e.g., "iPhone", "Desktop")
</p> </p>
</div> </div>
<div> <div>
<label for="pushover_sound" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="pushover_sound" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
Notification Sound (Optional) Notification Sound (Optional)
</label> </label>
<select id="pushover_sound" <select id="pushover_sound"
name="pushover_sound" name="pushover_sound"
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">
<option value="">Default (based on priority)</option> <option value="">Default (based on priority)</option>
<option value="pushover">Pushover (default)</option> <option value="pushover">Pushover (default)</option>
<option value="bike">Bike</option> <option value="bike">Bike</option>
@@ -356,58 +350,58 @@ ob_start();
<option value="vibrate">Vibrate Only</option> <option value="vibrate">Vibrate Only</option>
<option value="none">None (silent)</option> <option value="none">None (silent)</option>
</select> </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">
Custom sound for notifications. If not set, sound will be chosen based on urgency. Custom sound for notifications. If not set, sound will be chosen based on urgency.
</p> </p>
</div> </div>
</div> </div>
<!-- Generic Webhook Fields --> {# Generic Webhook Fields #}
<div id="webhook_fields" class="hidden space-y-4"> <div id="webhook_fields" class="hidden space-y-4">
<div> <div>
<label for="webhook_format" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="webhook_format" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
Webhook Format Webhook Format
</label> </label>
<select id="webhook_format" <select id="webhook_format"
name="webhook_format" name="webhook_format"
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"
onchange="updateWebhookPlaceholder()"> onchange="updateWebhookPlaceholder()">
<option value="generic">Generic (n8n/Zapier/Make)</option> <option value="generic">Generic (n8n/Zapier/Make)</option>
<option value="google_chat">Google Chat</option> <option value="google_chat">Google Chat</option>
<option value="simple_text">Simple Text ({"text":"..."})</option> <option value="simple_text">Simple Text ({"text":"..."})</option>
</select> </select>
<p id="webhook_format_help" class="mt-1.5 text-xs text-gray-500"> <p id="webhook_format_help" class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
Choose the payload format for your webhook endpoint. Choose the payload format for your webhook endpoint.
</p> </p>
</div> </div>
<div> <div>
<label for="generic_webhook_url" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="generic_webhook_url" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">
Webhook URL <span class="text-red-500">*</span> Webhook URL <span class="text-red-500">*</span>
</label> </label>
<input type="text" <input type="text"
id="generic_webhook_url" id="generic_webhook_url"
name="webhook_url" name="webhook_url"
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 focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm font-mono bg-white dark:bg-slate-700 text-gray-900 dark:text-white"
placeholder="https://example.com/webhook-endpoint" placeholder="https://example.com/webhook-endpoint"
autocomplete="off"> autocomplete="off">
<p id="webhook_url_help" class="mt-1.5 text-xs text-gray-500"> <p id="webhook_url_help" class="mt-1.5 text-xs text-gray-500 dark:text-slate-400">
Will receive JSON payload compatible with n8n/Zapier/Make. Will receive JSON payload compatible with n8n/Zapier/Make.
</p> </p>
</div> </div>
<!-- Google Chat specific help --> {# Google Chat specific help #}
<div id="google_chat_help" class="hidden bg-green-50 border border-green-200 rounded-lg p-4"> <div id="google_chat_help" class="hidden bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
<h4 class="text-sm font-medium text-green-800 flex items-center mb-2"> <h4 class="text-sm font-medium text-green-800 dark:text-green-400 flex items-center mb-2">
<i class="fas fa-info-circle mr-2"></i> <i class="fas fa-info-circle mr-2"></i>
Google Chat Setup Instructions Google Chat Setup Instructions
</h4> </h4>
<ol class="text-xs text-green-700 space-y-1 list-decimal list-inside"> <ol class="text-xs text-green-700 dark:text-green-400 space-y-1 list-decimal list-inside">
<li>Open your Google Chat space</li> <li>Open your Google Chat space</li>
<li>Click the space name <strong>Apps & integrations</strong></li> <li>Click the space name &rarr; <strong>Apps & integrations</strong></li>
<li>Click <strong>+ Add webhooks</strong></li> <li>Click <strong>+ Add webhooks</strong></li>
<li>Enter a name (e.g., "Domain Monitor") and optionally add an avatar</li> <li>Enter a name (e.g., "Domain Monitor") and optionally add an avatar</li>
<li>Click <strong>Save</strong> and copy the webhook URL</li> <li>Click <strong>Save</strong> and copy the webhook URL</li>
<li>Paste the URL above (starts with <code>https://chat.googleapis.com/v1/spaces/...</code>)</li> <li>Paste the URL above (starts with <code class="bg-white dark:bg-slate-700 px-1 rounded">https://chat.googleapis.com/v1/spaces/...</code>)</li>
</ol> </ol>
</div> </div>
</div> </div>
@@ -432,49 +426,47 @@ ob_start();
</div> </div>
</div> </div>
<!-- Assigned Domains --> {# Assigned Domains #}
<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">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 flex items-center"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-globe text-gray-400 mr-2 text-sm"></i> <i class="fas fa-globe text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
Assigned Domains (<?= count($group['domains']) ?>) Assigned Domains ({{ group.domains|length }})
</h2> </h2>
</div> </div>
<div class="p-6"> <div class="p-6">
<?php if (empty($group['domains'])): ?> {% if group.domains is empty %}
<div class="text-center py-10"> <div class="text-center py-10">
<i class="fas fa-globe text-gray-300 text-5xl mb-3"></i> <i class="fas fa-globe text-gray-300 dark:text-slate-600 text-5xl mb-3"></i>
<p class="text-gray-500">No domains assigned to this group yet</p> <p class="text-gray-500 dark:text-slate-400">No domains assigned to this group yet</p>
<a href="/domains/create" class="mt-3 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"> <a href="/domains/create" class="mt-3 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> <i class="fas fa-plus mr-2"></i>
Add a Domain Add a Domain
</a> </a>
</div> </div>
<?php else: ?> {% else %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<?php foreach ($group['domains'] as $domain): ?> {% for domain in group.domains %}
<a href="/domains/<?= $domain['id'] ?>" class="block bg-gray-50 border border-gray-200 rounded-lg p-6 hover:shadow-md hover:border-primary transition-all duration-200"> {% set statusClass = domain.status == 'active' ? 'bg-green-100 dark:bg-green-500/20 text-green-800 dark:text-green-400' : 'bg-gray-200 dark:bg-slate-600 text-gray-600 dark:text-slate-400' %}
<a href="/domains/{{ domain.id }}" class="block bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-lg p-6 hover:shadow-md hover:border-primary transition-all duration-200">
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div class="w-12 h-12 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<i class="fas fa-globe text-primary text-xl"></i> <i class="fas fa-globe text-primary text-xl"></i>
</div> </div>
<?php <span class="px-3 py-1 rounded-full text-xs font-semibold {{ statusClass }}">
$statusClass = $domain['status'] === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-200 text-gray-600'; {{ domain.status|capitalize }}
?>
<span class="px-3 py-1 rounded-full text-xs font-semibold <?= $statusClass ?>">
<?= ucfirst($domain['status']) ?>
</span> </span>
</div> </div>
<h3 class="font-semibold text-gray-800 mb-2 truncate"><?= htmlspecialchars($domain['domain_name']) ?></h3> <h3 class="font-semibold text-gray-800 dark:text-slate-200 mb-2 truncate">{{ domain.domain_name }}</h3>
<p class="text-sm text-gray-600 flex items-center"> <p class="text-sm text-gray-600 dark:text-slate-400 flex items-center">
<i class="far fa-calendar mr-2"></i> <i class="far fa-calendar mr-2"></i>
Expires: <?= $domain['expiration_date'] ? date('M j, Y', strtotime($domain['expiration_date'])) : 'Unknown' ?> Expires: {{ domain.expiration_date ? domain.expiration_date|date('M j, Y') : 'Unknown' }}
</p> </p>
</a> </a>
<?php endforeach; ?> {% endfor %}
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -484,7 +476,6 @@ function toggleChannelFields() {
const channelType = document.getElementById('channel_type').value; const channelType = document.getElementById('channel_type').value;
const testBtn = document.getElementById('testChannelBtn'); const testBtn = document.getElementById('testChannelBtn');
// Get all input fields
const emailField = document.getElementById('email'); const emailField = document.getElementById('email');
const botTokenField = document.getElementById('bot_token'); const botTokenField = document.getElementById('bot_token');
const chatIdField = document.getElementById('chat_id'); const chatIdField = document.getElementById('chat_id');
@@ -495,7 +486,6 @@ function toggleChannelFields() {
const pushoverUserKey = document.getElementById('pushover_user_key'); const pushoverUserKey = document.getElementById('pushover_user_key');
const genericWebhook = document.getElementById('generic_webhook_url'); const genericWebhook = document.getElementById('generic_webhook_url');
// Remove required from all
emailField.removeAttribute('required'); emailField.removeAttribute('required');
botTokenField.removeAttribute('required'); botTokenField.removeAttribute('required');
chatIdField.removeAttribute('required'); chatIdField.removeAttribute('required');
@@ -506,7 +496,6 @@ function toggleChannelFields() {
if (pushoverUserKey) pushoverUserKey.removeAttribute('required'); if (pushoverUserKey) pushoverUserKey.removeAttribute('required');
if (genericWebhook) genericWebhook.removeAttribute('required'); if (genericWebhook) genericWebhook.removeAttribute('required');
// Hide all fields
document.getElementById('email_fields').classList.add('hidden'); document.getElementById('email_fields').classList.add('hidden');
document.getElementById('telegram_fields').classList.add('hidden'); document.getElementById('telegram_fields').classList.add('hidden');
document.getElementById('discord_fields').classList.add('hidden'); document.getElementById('discord_fields').classList.add('hidden');
@@ -515,14 +504,11 @@ function toggleChannelFields() {
document.getElementById('pushover_fields').classList.add('hidden'); document.getElementById('pushover_fields').classList.add('hidden');
document.getElementById('webhook_fields').classList.add('hidden'); document.getElementById('webhook_fields').classList.add('hidden');
// Hide test button by default
testBtn.classList.add('hidden'); testBtn.classList.add('hidden');
// Show selected field and make required
if (channelType) { if (channelType) {
document.getElementById(channelType + '_fields').classList.remove('hidden'); document.getElementById(channelType + '_fields').classList.remove('hidden');
// Set required based on type
switch(channelType) { switch(channelType) {
case 'email': case 'email':
emailField.setAttribute('required', 'required'); emailField.setAttribute('required', 'required');
@@ -533,7 +519,7 @@ function toggleChannelFields() {
break; break;
case 'discord': case 'discord':
discordWebhook.setAttribute('required', 'required'); discordWebhook.setAttribute('required', 'required');
discordWebhook.focus(); // Auto-focus for easy paste discordWebhook.focus();
break; break;
case 'slack': case 'slack':
slackWebhook.setAttribute('required', 'required'); slackWebhook.setAttribute('required', 'required');
@@ -558,13 +544,11 @@ function toggleChannelFields() {
break; break;
} }
// Show test button when channel type is selected
testBtn.classList.remove('hidden'); testBtn.classList.remove('hidden');
} }
} }
// Form validation before submit const addChannelForm = document.querySelector('form[action="/groups/{{ group.id }}/channels"]');
const addChannelForm = document.querySelector('form[action="/groups/<?= $group['id'] ?>/channels"]');
if (addChannelForm) { if (addChannelForm) {
addChannelForm.addEventListener('submit', function(e) { addChannelForm.addEventListener('submit', function(e) {
const channelType = document.getElementById('channel_type').value; const channelType = document.getElementById('channel_type').value;
@@ -575,7 +559,6 @@ if (addChannelForm) {
return false; return false;
} }
// Validate Discord webhook
if (channelType === 'discord') { if (channelType === 'discord') {
const webhookField = document.getElementById('discord_webhook'); const webhookField = document.getElementById('discord_webhook');
const webhookUrl = webhookField.value.trim(); const webhookUrl = webhookField.value.trim();
@@ -594,7 +577,6 @@ if (addChannelForm) {
} }
} }
// Validate Slack webhook
if (channelType === 'slack') { if (channelType === 'slack') {
const webhookUrl = document.getElementById('slack_webhook').value.trim(); const webhookUrl = document.getElementById('slack_webhook').value.trim();
if (!webhookUrl) { if (!webhookUrl) {
@@ -605,7 +587,6 @@ if (addChannelForm) {
} }
} }
// Validate Mattermost webhook
if (channelType === 'mattermost') { if (channelType === 'mattermost') {
const webhookUrl = document.getElementById('mattermost_webhook').value.trim(); const webhookUrl = document.getElementById('mattermost_webhook').value.trim();
if (!webhookUrl) { if (!webhookUrl) {
@@ -616,7 +597,6 @@ if (addChannelForm) {
} }
} }
// Validate Generic webhook
if (channelType === 'webhook') { if (channelType === 'webhook') {
const webhookUrl = document.getElementById('generic_webhook_url').value.trim(); const webhookUrl = document.getElementById('generic_webhook_url').value.trim();
if (!webhookUrl) { if (!webhookUrl) {
@@ -631,14 +611,10 @@ if (addChannelForm) {
}); });
} }
// Test channel functionality - handles both new and existing channels
function testChannel(channelType, existingConfig = null) { function testChannel(channelType, existingConfig = null) {
// If existingConfig is provided, we're testing an existing channel
// If not, we're testing a new channel from the form
const isExistingChannel = existingConfig !== null; const isExistingChannel = existingConfig !== null;
if (!isExistingChannel) { if (!isExistingChannel) {
// For new channels, get values from form
channelType = document.getElementById('channel_type').value; channelType = document.getElementById('channel_type').value;
const testBtn = document.getElementById('testChannelBtn'); const testBtn = document.getElementById('testChannelBtn');
@@ -647,7 +623,6 @@ function testChannel(channelType, existingConfig = null) {
return; return;
} }
// Validate required fields before testing
let isValid = true; let isValid = true;
let errorMessage = ''; let errorMessage = '';
@@ -737,31 +712,24 @@ function testChannel(channelType, existingConfig = null) {
return; return;
} }
// Disable button and show loading state for new channels
testBtn.disabled = true; testBtn.disabled = true;
testBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Testing...'; testBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Testing...';
} }
// Create form data for AJAX request
const formData = new FormData(); const formData = new FormData();
formData.append('channel_type', channelType); formData.append('channel_type', channelType);
// Add group ID from URL or form
let groupId = document.querySelector('input[name="group_id"]')?.value; let groupId = document.querySelector('input[name="group_id"]')?.value;
if (!groupId) { if (!groupId) {
// Extract group ID from URL if not in form
const urlParts = window.location.pathname.split('/'); const urlParts = window.location.pathname.split('/');
groupId = urlParts[urlParts.indexOf('groups') + 1]; groupId = urlParts[urlParts.indexOf('groups') + 1];
} }
formData.append('group_id', groupId); formData.append('group_id', groupId);
// Add CSRF token
const csrfToken = document.querySelector('input[name="csrf_token"]').value; const csrfToken = document.querySelector('input[name="csrf_token"]').value;
formData.append('csrf_token', csrfToken); formData.append('csrf_token', csrfToken);
// Add channel-specific data
if (isExistingChannel) { if (isExistingChannel) {
// Use existing channel config
switch(channelType) { switch(channelType) {
case 'email': case 'email':
formData.append('email', existingConfig.email); formData.append('email', existingConfig.email);
@@ -797,7 +765,6 @@ function testChannel(channelType, existingConfig = null) {
break; break;
} }
} else { } else {
// Use form values for new channels
switch(channelType) { switch(channelType) {
case 'email': case 'email':
formData.append('email', document.getElementById('email').value); formData.append('email', document.getElementById('email').value);
@@ -837,7 +804,6 @@ function testChannel(channelType, existingConfig = null) {
} }
} }
// Send AJAX request
fetch('/channels/test', { fetch('/channels/test', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -847,7 +813,6 @@ function testChannel(channelType, existingConfig = null) {
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
// Reset button for new channels
if (!isExistingChannel) { if (!isExistingChannel) {
const testBtn = document.getElementById('testChannelBtn'); const testBtn = document.getElementById('testChannelBtn');
testBtn.disabled = false; testBtn.disabled = false;
@@ -861,19 +826,16 @@ function testChannel(channelType, existingConfig = null) {
} }
}) })
.catch(error => { .catch(error => {
// Reset button for new channels
if (!isExistingChannel) { if (!isExistingChannel) {
const testBtn = document.getElementById('testChannelBtn'); const testBtn = document.getElementById('testChannelBtn');
testBtn.disabled = false; testBtn.disabled = false;
testBtn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Test Channel'; testBtn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Test Channel';
} }
showToast('Test failed: ' + error.message + ' Please check your configuration and try again.', 'error'); showToast('Test failed: ' + error.message + ' Please check your configuration and try again.', 'error');
}); });
} }
// Function to show toast messages dynamically
function showToast(message, type = 'info') { function showToast(message, type = 'info') {
const toastContainer = document.getElementById('toast-container'); const toastContainer = document.getElementById('toast-container');
if (!toastContainer) return; if (!toastContainer) return;
@@ -912,7 +874,7 @@ function showToast(message, type = 'info') {
const config = typeConfig[type] || typeConfig.info; const config = typeConfig[type] || typeConfig.info;
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast bg-white border-l-4 ${config.borderColor} rounded-lg shadow-lg p-4 flex items-start animate-slide-in`; toast.className = `toast bg-white dark:bg-slate-800 border-l-4 ${config.borderColor} rounded-lg shadow-lg p-4 flex items-start animate-slide-in`;
toast.innerHTML = ` toast.innerHTML = `
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-8 h-8 ${config.bgColor} rounded-full flex items-center justify-center"> <div class="w-8 h-8 ${config.bgColor} rounded-full flex items-center justify-center">
@@ -920,17 +882,16 @@ function showToast(message, type = 'info') {
</div> </div>
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3 flex-1">
<p class="text-sm font-medium text-gray-900">${config.title}</p> <p class="text-sm font-medium text-gray-900 dark:text-white">${config.title}</p>
<p class="text-sm text-gray-600 mt-0.5">${message}</p> <p class="text-sm text-gray-600 dark:text-slate-400 mt-0.5">${message}</p>
</div> </div>
<button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"> <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> <i class="fas fa-times text-sm"></i>
</button> </button>
`; `;
toastContainer.appendChild(toast); toastContainer.appendChild(toast);
// Auto-dismiss after 5 seconds
setTimeout(() => { setTimeout(() => {
toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out';
toast.style.opacity = '0'; toast.style.opacity = '0';
@@ -942,7 +903,6 @@ function showToast(message, type = 'info') {
}, 5000); }, 5000);
} }
// Update webhook placeholder and help text based on selected format
function updateWebhookPlaceholder() { function updateWebhookPlaceholder() {
const format = document.getElementById('webhook_format').value; const format = document.getElementById('webhook_format').value;
const urlInput = document.getElementById('generic_webhook_url'); const urlInput = document.getElementById('generic_webhook_url');
@@ -950,7 +910,6 @@ function updateWebhookPlaceholder() {
const formatHelp = document.getElementById('webhook_format_help'); const formatHelp = document.getElementById('webhook_format_help');
const googleChatHelp = document.getElementById('google_chat_help'); const googleChatHelp = document.getElementById('google_chat_help');
// Update placeholder and help based on format
switch(format) { switch(format) {
case 'google_chat': case 'google_chat':
urlInput.placeholder = 'https://chat.googleapis.com/v1/spaces/XXXXX/messages?key=...'; urlInput.placeholder = 'https://chat.googleapis.com/v1/spaces/XXXXX/messages?key=...';
@@ -960,11 +919,11 @@ function updateWebhookPlaceholder() {
break; break;
case 'simple_text': case 'simple_text':
urlInput.placeholder = 'https://example.com/webhook-endpoint'; urlInput.placeholder = 'https://example.com/webhook-endpoint';
urlHelp.innerHTML = 'Sends simple JSON payload: <code class="bg-gray-100 px-1 rounded">{"text":"message"}</code>'; urlHelp.innerHTML = 'Sends simple JSON payload: <code class="bg-gray-100 dark:bg-slate-700 px-1 rounded">{"text":"message"}</code>';
formatHelp.textContent = 'Compatible with services expecting simple text payloads.'; formatHelp.textContent = 'Compatible with services expecting simple text payloads.';
googleChatHelp.classList.add('hidden'); googleChatHelp.classList.add('hidden');
break; break;
default: // generic default:
urlInput.placeholder = 'https://example.com/webhook-endpoint'; urlInput.placeholder = 'https://example.com/webhook-endpoint';
urlHelp.textContent = 'Will receive JSON payload compatible with n8n/Zapier/Make.'; urlHelp.textContent = 'Will receive JSON payload compatible with n8n/Zapier/Make.';
formatHelp.textContent = 'Sends structured JSON with event type, message, data, and timestamp.'; formatHelp.textContent = 'Sends structured JSON with event type, message, data, and timestamp.';
@@ -973,8 +932,4 @@ function updateWebhookPlaceholder() {
} }
} }
</script> </script>
{% endblock %}
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -1,32 +1,32 @@
<?php {% extends 'layout/base.twig' %}
$title = 'Notification Groups';
$pageTitle = 'Notification Groups';
$pageDescription = 'Manage notification channels and assignments';
$pageIcon = 'fas fa-bell';
ob_start();
?>
<!-- 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"> <div class="mb-4 flex gap-2 justify-end">
<!-- Export Dropdown --> {# Export Dropdown #}
<div class="relative" id="groupExportDropdownWrapper"> <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"> <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> <i class="fas fa-download mr-2"></i>
Export Export
<i class="fas fa-chevron-down ml-2 text-xs"></i> <i class="fas fa-chevron-down ml-2 text-xs"></i>
</button> </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"> <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 hover:bg-gray-50 transition-colors"> <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> <i class="fas fa-file-csv text-green-600 mr-2.5"></i>
Export as CSV Export as CSV
</a> </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> <i class="fas fa-file-code text-blue-600 mr-2.5"></i>
Export as JSON Export as JSON
</a> </a>
</div> </div>
</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"> <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> <i class="fas fa-upload mr-2"></i>
Import Import
@@ -37,15 +37,15 @@ ob_start();
</a> </a>
</div> </div>
<!-- Info Card --> {# Info Card #}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4"> <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 items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<i class="fas fa-info-circle text-blue-500 text-lg"></i> <i class="fas fa-info-circle text-blue-500 text-lg"></i>
</div> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">About Notification Groups</h3> <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 leading-relaxed"> <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 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 (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. is about to expire, all active channels in its group will receive notifications.
@@ -54,50 +54,50 @@ ob_start();
</div> </div>
</div> </div>
<!-- Groups List --> {# Groups List #}
<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">
<!-- Bulk Actions Bar (shown when groups are selected) --> {# 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"> <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"> <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-3 flex-wrap">
<div class="flex items-center gap-2"> <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"> <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 <i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
</button> </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"> <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 <i class="fas fa-trash mr-1"></i> Delete Selected
</button> </button>
</div> </div>
</div> </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 <i class="fas fa-times mr-1.5"></i> Clear Selection
</button> </button>
</div> </div>
<?php if (!empty($groups)): ?> {% if groups is not empty %}
<!-- Table View (Desktop) --> {# Table View (Desktop) #}
<div class="hidden md:block overflow-x-auto"> <div class="hidden md:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
<thead class="bg-gray-50"> <thead class="bg-gray-50 dark:bg-slate-900">
<tr> <tr>
<th class="px-6 py-3 text-left"> <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>
<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 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 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">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 dark:text-slate-400 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-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 uppercase tracking-wider">Actions</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> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
<?php foreach ($groups as $group): ?> {% for group in groups %}
<tr class="hover:bg-gray-50 transition-colors duration-150"> <tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
<td class="px-6 py-4"> <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>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center"> <div class="flex items-center">
@@ -105,108 +105,108 @@ ob_start();
<i class="fas fa-bell text-primary"></i> <i class="fas fa-bell text-primary"></i>
</div> </div>
<div class="ml-4"> <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>
</div> </div>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="text-sm text-gray-700 max-w-xs truncate"> <div class="text-sm text-gray-700 dark:text-slate-300 max-w-xs truncate">
<?= htmlspecialchars($group['description'] ?? 'No description') ?> {{ group.description|default('No description') }}
</div> </div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <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> <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> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <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> <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> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2"> <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> <i class="fas fa-cog"></i>
</a> </a>
<?php if (\Core\Auth::isAdmin()): ?> {% if auth.isAdmin %}
<button onclick="transferGroup(<?= $group['id'] ?>, '<?= htmlspecialchars($group['name']) ?>')" <button onclick="transferGroup({{ group.id }}, '{{ group.name|e('js') }}')"
class="text-green-600 hover:text-green-800" class="text-green-600 hover:text-green-800"
title="Transfer Group"> title="Transfer Group">
<i class="fas fa-exchange-alt"></i> <i class="fas fa-exchange-alt"></i>
</button> </button>
<?php endif; ?> {% endif %}
<form method="POST" action="/groups/<?= $group['id'] ?>/delete" class="inline" onsubmit="return confirm('Are you sure? Domains will be unassigned from this group.')"> <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() ?>"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-600 hover:text-red-800" title="Delete" <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> <i class="fas fa-trash"></i>
</button> </button>
</form> </form>
</div> </div>
</td> </td>
</tr> </tr>
<?php endforeach; ?> {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Card View (Mobile) --> {# Card View (Mobile) #}
<div class="md:hidden divide-y divide-gray-200"> <div class="md:hidden divide-y divide-gray-200 dark:divide-slate-700">
<?php foreach ($groups as $group): ?> {% for group in groups %}
<div class="p-6 hover:bg-gray-50 transition-colors duration-150"> <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-start justify-between mb-3">
<div class="flex items-center"> <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"> <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> <i class="fas fa-bell text-primary"></i>
</div> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="font-semibold text-gray-900"><?= htmlspecialchars($group['name']) ?></h3> <h3 class="font-semibold text-gray-900 dark:text-white">{{ group.name }}</h3>
<p class="text-sm text-gray-500"><?= htmlspecialchars($group['description'] ?? 'No description') ?></p> <p class="text-sm text-gray-500 dark:text-slate-400">{{ group.description|default('No description') }}</p>
</div> </div>
</div> </div>
</div> </div>
<div class="flex space-x-3 mb-3"> <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> <i class="fas fa-plug mr-1"></i>
<?= $group['channel_count'] ?> channels {{ group.channel_count }} channels
</span> </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> <i class="fas fa-globe mr-1"></i>
<?= $group['domain_count'] ?> domains {{ group.domain_count }} domains
</span> </span>
</div> </div>
<div class="flex space-x-2"> <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 <i class="fas fa-cog mr-1"></i> Manage
</a> </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.')"> <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() ?>"> <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"> <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 <i class="fas fa-trash mr-1"></i> Delete
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<?php endforeach; ?> {% endfor %}
</div> </div>
<?php else: ?> {% else %}
<div class="text-center py-12 px-6"> <div class="text-center py-12 px-6">
<div class="mb-4"> <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> </div>
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Notification Groups</h3> <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 mb-4">Create your first notification group to start receiving alerts</p> <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"> <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> <i class="fas fa-plus mr-2"></i>
Create Your First Group Create Your First Group
</a> </a>
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
<script> <script>
@@ -231,7 +231,6 @@ function updateBulkActions() {
bulkActions.classList.add('hidden'); bulkActions.classList.add('hidden');
} }
// Update select all checkbox state
const allCheckboxes = document.querySelectorAll('.group-checkbox'); const allCheckboxes = document.querySelectorAll('.group-checkbox');
if (selectAllCheckbox) { if (selectAllCheckbox) {
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length; selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
@@ -272,7 +271,7 @@ function bulkDelete() {
const csrfInput = document.createElement('input'); const csrfInput = document.createElement('input');
csrfInput.type = 'hidden'; csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token'; csrfInput.name = 'csrf_token';
csrfInput.value = '<?= csrf_token() ?>'; csrfInput.value = '{{ csrf_token() }}';
form.appendChild(csrfInput); form.appendChild(csrfInput);
const idsInput = document.createElement('input'); const idsInput = document.createElement('input');
@@ -285,9 +284,8 @@ function bulkDelete() {
form.submit(); form.submit();
} }
// Transfer single group
function transferGroup(groupId, groupName) { function transferGroup(groupId, groupName) {
const users = <?= json_encode($users ?? []) ?>; const users = {{ users|default([])|json_encode|raw }};
if (users.length === 0) { if (users.length === 0) {
alert('No users available for transfer'); alert('No users available for transfer');
@@ -301,24 +299,24 @@ function transferGroup(groupId, groupName) {
const modal = document.createElement('div'); 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.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = ` modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6"> <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 mb-1">Transfer Group</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Group</h3>
<p class="text-sm text-gray-600 mb-4">Transfer group "${groupName}" to another user.</p> <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"> <form method="POST" action="/groups/transfer">
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="group_id" value="${groupId}"> <input type="hidden" name="group_id" value="${groupId}">
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label> <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 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"> <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> <option value="">Select User</option>
${userOptions} ${userOptions}
</select> </select>
</div> </div>
<div class="flex justify-end gap-3"> <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"> <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 Cancel
</button> </button>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium"> <button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
@@ -332,7 +330,6 @@ function transferGroup(groupId, groupName) {
document.body.appendChild(modal); document.body.appendChild(modal);
} }
// Bulk transfer groups
function bulkTransfer() { function bulkTransfer() {
const groupIds = getSelectedGroupIds(); const groupIds = getSelectedGroupIds();
if (groupIds.length === 0) { if (groupIds.length === 0) {
@@ -340,7 +337,7 @@ function bulkTransfer() {
return; return;
} }
const users = <?= json_encode($users ?? []) ?>; const users = {{ users|default([])|json_encode|raw }};
if (users.length === 0) { if (users.length === 0) {
alert('No users available for transfer'); alert('No users available for transfer');
@@ -354,26 +351,26 @@ function bulkTransfer() {
const modal = document.createElement('div'); 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.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = ` modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6"> <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 mb-1">Transfer Groups</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Groups</h3>
<p class="text-sm text-gray-600 mb-4">Transfer ${groupIds.length} selected group(s) to another user.</p> <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"> <form method="POST" action="/groups/bulk-transfer">
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
${groupIds.map(id => ${groupIds.map(id =>
`<input type="hidden" name="group_ids[]" value="${id}">` `<input type="hidden" name="group_ids[]" value="${id}">`
).join('')} ).join('')}
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label> <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 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"> <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> <option value="">Select User</option>
${userOptions} ${userOptions}
</select> </select>
</div> </div>
<div class="flex justify-end gap-3"> <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"> <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 Cancel
</button> </button>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium"> <button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
@@ -387,7 +384,6 @@ function bulkTransfer() {
document.body.appendChild(modal); document.body.appendChild(modal);
} }
// Close export dropdown when clicking outside
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const wrapper = document.getElementById('groupExportDropdownWrapper'); const wrapper = document.getElementById('groupExportDropdownWrapper');
if (wrapper && !wrapper.contains(e.target)) { 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) { document.getElementById('groupImportModal')?.addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) {
this.classList.add('hidden'); this.classList.add('hidden');
@@ -403,54 +398,54 @@ document.getElementById('groupImportModal')?.addEventListener('click', function(
}); });
</script> </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 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="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 flex items-center justify-between"> <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"> <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 <i class="fas fa-upload text-primary mr-2"></i>Import Notification Groups
</h3> </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> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
<form method="POST" action="/groups/import" enctype="multipart/form-data" id="groupImportForm"> <form method="POST" action="/groups/import" enctype="multipart/form-data" id="groupImportForm">
<?= csrf_field() ?> {{ csrf_field() }}
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<!-- Drag & Drop Zone --> {# Drag & Drop Zone #}
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Select File</label> <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 rounded-lg p-6 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50"> <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" <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"> class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<div id="groupDropzoneContent"> <div id="groupDropzoneContent">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i> <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 font-medium">Drag & drop your file here</p> <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 my-1">or</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"> <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 <i class="fas fa-folder-open mr-1.5"></i>Browse Files
</span> </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>
<div id="groupDropzoneFile" class="hidden"> <div id="groupDropzoneFile" class="hidden">
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i> <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-sm font-medium text-gray-700 dark:text-slate-300" id="groupFileName"></p>
<p class="text-xs text-gray-400" id="groupFileSize"></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"> <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 <i class="fas fa-trash-alt mr-1"></i>Remove
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3"> <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 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-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">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 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 mt-0.5">JSON: array of group objects with nested channels array</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 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> <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> </div>
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-2 rounded-b-lg"> <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 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors"> <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 Cancel
</button> </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"> <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> </div>
<script> <script>
// --- Group Import drag-and-drop & loading ---
(function() { (function() {
const dropzone = document.getElementById('groupDropzone'); const dropzone = document.getElementById('groupDropzone');
const fileInput = document.getElementById('groupFileInput'); const fileInput = document.getElementById('groupFileInput');
@@ -540,8 +534,4 @@ document.getElementById('groupImportModal')?.addEventListener('click', function(
}); });
})(); })();
</script> </script>
{% endblock %}
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -24,7 +24,6 @@
<body class="min-h-screen flex items-center justify-center p-4"> <body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full"> <div class="max-w-2xl w-full">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8"> <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<!-- Success Icon -->
<div class="text-center mb-8"> <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"> <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> <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> <p class="text-gray-600">Domain Monitor is ready to use</p>
</div> </div>
<!-- Important Notice -->
<div class="bg-amber-50 border-2 border-amber-400 rounded-lg p-6 mb-6"> <div class="bg-amber-50 border-2 border-amber-400 rounded-lg p-6 mb-6">
<div class="flex items-start"> <div class="flex items-start">
<i class="fas fa-exclamation-triangle text-amber-600 text-2xl mr-4"></i> <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="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600">Username:</span> <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>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600">Password:</span> <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> </div>
</div> </div>
@@ -57,7 +55,6 @@
</div> </div>
</div> </div>
<!-- Success Checklist -->
<div class="bg-gray-50 rounded-lg border border-gray-200 p-6 mb-6"> <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> <h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4">Installation Summary</h3>
<div class="space-y-3"> <div class="space-y-3">
@@ -80,15 +77,14 @@
</div> </div>
</div> </div>
<!-- Next Steps -->
<div class="bg-blue-50 rounded-lg border border-blue-200 p-4 mb-6"> <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"> <h3 class="text-sm font-semibold text-blue-900 mb-3">
<i class="fas fa-lightbulb mr-2"></i>Next Steps <i class="fas fa-lightbulb mr-2"></i>Next Steps
</h3> </h3>
<ol class="text-sm text-blue-800 space-y-1 ml-5 list-decimal"> <ol class="text-sm text-blue-800 space-y-1 ml-5 list-decimal">
<li>Log in with your admin credentials</li> <li>Log in with your admin credentials</li>
<li>Configure email settings (Settings Email)</li> <li>Configure email settings (Settings &rarr; Email)</li>
<li>Import TLD registry data (TLD Registry Import TLDs)</li> <li>Import TLD registry data (TLD Registry &rarr; Import TLDs)</li>
<li>Add your first domain</li> <li>Add your first domain</li>
<li>Set up notification groups</li> <li>Set up notification groups</li>
<li>Configure cron job for automated monitoring</li> <li>Configure cron job for automated monitoring</li>
@@ -102,7 +98,7 @@
</div> </div>
<div class="text-center mt-6"> <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>
</div> </div>
</body> </body>

View File

@@ -24,7 +24,6 @@
<body class="min-h-screen flex items-center justify-center p-4"> <body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full"> <div class="max-w-2xl w-full">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8"> <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<!-- Header -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-lg mb-4"> <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> <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> <p class="text-gray-600">New database migrations are available</p>
</div> </div>
<!-- Warning -->
<div class="bg-amber-50 border border-amber-300 rounded-lg p-4 mb-6"> <div class="bg-amber-50 border border-amber-300 rounded-lg p-4 mb-6">
<div class="flex items-start"> <div class="flex items-start">
<i class="fas fa-exclamation-triangle text-amber-600 text-xl mr-3"></i> <i class="fas fa-exclamation-triangle text-amber-600 text-xl mr-3"></i>
@@ -44,38 +42,35 @@
</div> </div>
</div> </div>
<!-- Pending Migrations -->
<div class="mb-6"> <div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-3">Pending Migrations</h2> <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"> <div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<ul class="space-y-2"> <ul class="space-y-2">
<?php foreach ($migrations as $migration): ?> {% for migration in migrations %}
<li class="flex items-center text-sm"> <li class="flex items-center text-sm">
<i class="fas fa-circle text-xs text-gray-400 mr-3"></i> <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> </li>
<?php endforeach; ?> {% endfor %}
</ul> </ul>
<div class="mt-3 pt-3 border-t border-gray-300"> <div class="mt-3 pt-3 border-t border-gray-300">
<p class="text-sm font-semibold text-gray-900"> <p class="text-sm font-semibold text-gray-900">
<i class="fas fa-database mr-2"></i> <i class="fas fa-database mr-2"></i>
Total: <?= count($migrations) ?> migration(s) Total: {{ migrations|length }} migration(s)
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<!-- Error Alert --> {% if flash.error is defined %}
<?php if (isset($_SESSION['error'])): ?>
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg"> <div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i> <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>
</div> </div>
<?php unset($_SESSION['error']); endif; ?> {% endif %}
<!-- Actions -->
<form method="POST" action="/install/update" class="space-y-3"> <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"> <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> <i class="fas fa-download mr-2"></i>
@@ -89,7 +84,7 @@
</div> </div>
<div class="text-center mt-6"> <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>
</div> </div>
</body> </body>

View File

@@ -23,9 +23,7 @@
</head> </head>
<body class="min-h-screen flex items-center justify-center p-4"> <body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full"> <div class="max-w-2xl w-full">
<!-- Installer Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8"> <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="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-lg mb-4"> <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> <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> <p class="text-gray-600">Welcome! Let's set up your monitoring system</p>
</div> </div>
<!-- Installation Steps -->
<div class="bg-gray-50 rounded-lg border border-gray-200 p-6 mb-6"> <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> <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4">Installation Steps</h2>
<div class="space-y-3"> <div class="space-y-3">
@@ -62,17 +59,15 @@
</div> </div>
</div> </div>
<!-- Error Alert --> {% if flash.error is defined %}
<?php if (isset($_SESSION['error'])): ?>
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg"> <div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
<div class="flex items-center"> <div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i> <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>
</div> </div>
<?php unset($_SESSION['error']); endif; ?> {% endif %}
<!-- Installation Form -->
<form method="POST" action="/install/run" class="space-y-5"> <form method="POST" action="/install/run" class="space-y-5">
<div class="border-t border-gray-200 pt-6"> <div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Administrator Account</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Administrator Account</h3>
@@ -142,9 +137,8 @@
</form> </form>
</div> </div>
<!-- Footer -->
<div class="text-center mt-6"> <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>
</div> </div>

View File

@@ -1,40 +1,8 @@
<?php {#
/** # Base Layout Template
* Base Layout Template # Contains: HTML structure, meta tags, CSS/JS includes, global stats
* 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -45,24 +13,25 @@ if (!isset($appName)) {
<meta name="author" content="Domain Monitor"> <meta name="author" content="Domain Monitor">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<!-- Title --> {# Title #}
<title><?= $title ?? 'Domain Monitor' ?> - <?= $appName ?></title> <title>{{ title|default('Domain Monitor') }} - {{ appName }}</title>
<!-- Favicon --> {# Favicon #}
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico"> <link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
<!-- Tailwind CSS --> {# Tailwind CSS #}
<script src="https://cdn.tailwindcss.com"></script> <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" /> <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" /> <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> <script>
tailwind.config = { tailwind.config = {
darkMode: 'class',
theme: { theme: {
extend: { extend: {
colors: { colors: {
@@ -81,37 +50,33 @@ if (!isset($appName)) {
} }
</script> </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"> <link rel="stylesheet" href="/assets/style.css">
<!-- Custom Page Styles (optional) --> {# Custom Page Styles (optional) #}
<?php if (isset($customStyles)): ?> {% if customStyles is defined %}
<style><?= $customStyles ?></style> <style>{{ customStyles|raw }}</style>
<?php endif; ?> {% endif %}
<style> <style>
/* Sidebar full height */ /* Sidebar full height */
.sidebar { .sidebar {
height: 100vh; 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 */ @media (max-width: 768px) {
.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) {
.sidebar { .sidebar {
transform: translateX(-100%); transform: translateX(-100%);
} }
@@ -133,145 +98,126 @@ if (!isset($appName)) {
transform: translateY(0); transform: translateY(0);
} }
/* Active sidebar link */ /* Sidebar link hover effect */
.sidebar-link.active { .sidebar-link {
background: #374151; position: relative;
border-left: 4px solid #4A90E2; }
.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 */ /* Custom scrollbar for sidebar */
@media (max-width: 480px) { .sidebar::-webkit-scrollbar {
#notificationsDropdown { width: 6px;
width: calc(100vw - 2rem);
right: -0.5rem;
} }
#userDropdown { .sidebar::-webkit-scrollbar-track {
width: calc(100vw - 2rem); background: transparent;
right: -0.5rem;
} }
.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> </style>
{% block head %}{% endblock %}
</head> </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 --> {# Mobile Sidebar Overlay #}
<div id="sidebarOverlay" class="sidebar-overlay md:hidden" onclick="closeSidebar()"></div> <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 Content Area #}
<main class="md:ml-64 pt-16 min-h-screen bg-gray-50"> <main class="md:ml-64 pt-16 min-h-screen bg-gray-50 dark:bg-gray-900">
<div class="p-6"> <div class="p-6">
<!-- Flash Messages --> {# Flash Messages #}
<?php include __DIR__ . '/messages.php'; ?> {% include 'layout/messages.twig' %}
<!-- Page Content --> {# Page Content #}
<?php if (isset($content)): ?> {% block content %}{% endblock %}
<?= $content ?>
<?php endif; ?>
</div> </div>
</main> </main>
<!-- Global Scripts --> {# Global Scripts #}
<script> <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 // Toggle sidebar on mobile
function toggleSidebar() { function toggleSidebar() {
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay'); const overlay = document.getElementById('sidebarOverlay');
sidebar.classList.toggle('open'); sidebar.classList.toggle('open');
overlay.classList.toggle('show'); overlay.classList.toggle('hidden');
// Prevent body scroll when sidebar is open
document.body.style.overflow = sidebar.classList.contains('open') ? 'hidden' : '';
} }
// Close sidebar (for overlay click and link clicks) // Close sidebar on mobile
function closeSidebar() { function closeSidebar() {
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebarOverlay'); const overlay = document.getElementById('sidebarOverlay');
sidebar.classList.remove('open'); sidebar.classList.remove('open');
overlay.classList.remove('show'); overlay.classList.add('hidden');
document.body.style.overflow = '';
} }
// Close sidebar when clicking a link (mobile only) function closeOtherDropdowns(except) {
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) {
['userDropdown', 'notificationsDropdown', 'quickActionsDropdown'].forEach(id => { ['userDropdown', 'notificationsDropdown', 'quickActionsDropdown'].forEach(id => {
if (id !== exceptId) { if (id !== except) {
const el = document.getElementById(id); const dd = document.getElementById(id);
if (el) el.classList.remove('show'); if (dd) dd.classList.remove('show');
} }
}); });
} }
// Toggle user dropdown
function toggleDropdown() { function toggleDropdown() {
closeOtherDropdowns('userDropdown'); closeOtherDropdowns('userDropdown');
document.getElementById('userDropdown').classList.toggle('show'); document.getElementById('userDropdown').classList.toggle('show');
} }
// Toggle notifications dropdown
function toggleNotifications() { function toggleNotifications() {
closeOtherDropdowns('notificationsDropdown'); closeOtherDropdowns('notificationsDropdown');
document.getElementById('notificationsDropdown').classList.toggle('show'); document.getElementById('notificationsDropdown').classList.toggle('show');
} }
// Toggle quick actions dropdown
function toggleQuickActions() { function toggleQuickActions() {
closeOtherDropdowns('quickActionsDropdown'); closeOtherDropdowns('quickActionsDropdown');
document.getElementById('quickActionsDropdown').classList.toggle('show'); document.getElementById('quickActionsDropdown').classList.toggle('show');
} }
// Close dropdowns when clicking outside
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
const dropdowns = [ const dropdowns = [
{ id: 'userDropdown', trigger: '[onclick="toggleDropdown()"]' }, { id: 'userDropdown', trigger: '[onclick="toggleDropdown()"]' },
@@ -344,6 +290,7 @@ if (!isset($appName)) {
function renderSearchResults(data) { function renderSearchResults(data) {
let html = ''; let html = '';
const isDark = document.documentElement.classList.contains('dark');
if (data.domains && data.domains.length > 0) { if (data.domains && data.domains.length > 0) {
html += '<div class="p-2">'; html += '<div class="p-2">';
@@ -360,11 +307,11 @@ if (!isset($appName)) {
const colorClass = statusColors[domain.status_color] || 'text-gray-600'; const colorClass = statusColors[domain.status_color] || 'text-gray-600';
html += ` 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 items-center justify-between">
<div class="flex-1 min-w-0"> <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-sm font-semibold text-gray-900 dark:text-white truncate">${escapeHtml(domain.domain_name)}</p>
<p class="text-xs text-gray-500">${escapeHtml(domain.registrar || 'Unknown registrar')}</p> <p class="text-xs text-gray-500 dark:text-gray-400">${escapeHtml(domain.registrar || 'Unknown registrar')}</p>
</div> </div>
${domain.days_left !== null ? ` ${domain.days_left !== null ? `
<div class="ml-3 text-right"> <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 // Show WHOIS lookup option if no results and looks like a domain
if (data.domains.length === 0 && data.isDomainLike) { if (data.domains.length === 0 && data.isDomainLike) {
html += ` 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 class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-gray-900">Domain not in portfolio</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 mt-0.5">Perform WHOIS lookup for ${escapeHtml(data.query)}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Perform WHOIS lookup for ${escapeHtml(data.query)}</p>
</div> </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"> <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 Lookup
@@ -395,14 +342,14 @@ if (!isset($appName)) {
</div> </div>
`; `;
} else if (data.domains.length === 0) { } 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 // Add "View all results" link if there are results
if (data.domains.length > 0) { if (data.domains.length > 0) {
html += ` html += `
<div class="border-t border-gray-200 p-2"> <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 rounded-lg"> <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 → View all results →
</a> </a>
</div> </div>
@@ -419,11 +366,12 @@ if (!isset($appName)) {
} }
</script> </script>
<!-- Custom Page Scripts (optional) --> {# Custom Page Scripts (optional) #}
<?php if (isset($customScripts)): ?> {% if customScripts is defined %}
<script><?= $customScripts ?></script> <script>{{ customScripts|raw }}</script>
<?php endif; ?> {% endif %}
{% block scripts %}{% endblock %}
</body> </body>
</html> </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 {% extends 'layout/base.twig' %}
$title = 'My Profile';
$pageTitle = 'My Profile';
$pageDescription = 'Manage your account settings and preferences';
$pageIcon = 'fas fa-user-circle';
ob_start();
// Get 2FA status {% set title = 'My Profile' %}
$twoFactorStatus = $userModel->getTwoFactorStatus($user['id']); {% set pageTitle = 'My Profile' %}
$twoFactorService = new \App\Services\TwoFactorService(); {% set pageDescription = 'Manage your account settings and preferences' %}
$twoFactorPolicy = $twoFactorService->getTwoFactorPolicy(); {% set pageIcon = 'fas fa-user-circle' %}
// Get avatar data
$avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
?>
{% block content %}
<!-- Main Profile Layout --> <!-- Main Profile Layout -->
<div class="grid grid-cols-12 gap-6"> <div class="grid grid-cols-12 gap-6">
<!-- Sidebar Navigation --> <!-- Sidebar Navigation -->
<div class="col-span-12 lg:col-span-3"> <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 --> <!-- 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="flex flex-col items-center text-center">
<div class="relative"> <div class="relative">
<?php if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar'): ?> {% if avatar.type == 'uploaded' or avatar.type == 'gravatar' %}
<img src="<?= htmlspecialchars($avatar['url']) ?>" <img src="{{ avatar.url }}"
alt="<?= htmlspecialchars($avatar['alt']) ?>" alt="{{ avatar.alt }}"
class="w-20 h-20 rounded-full object-cover border-2 border-white shadow-sm" class="w-20 h-20 rounded-full object-cover border-2 border-white dark:border-slate-700 shadow-sm"
loading="lazy"> loading="lazy">
<?php else: ?> {% 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"> <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'] ?> {{ avatar.initials }}
</div> </div>
<?php endif; ?> {% endif %}
<!-- Avatar type indicator --> <!-- 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"> <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">
<?php if ($avatar['type'] === 'uploaded'): ?> {% if avatar.type == 'uploaded' %}
<i class="fas fa-image text-xs text-blue-600" title="Uploaded avatar"></i> <i class="fas fa-image text-xs text-blue-600 dark:text-blue-400" title="Uploaded avatar"></i>
<?php elseif ($avatar['type'] === 'gravatar'): ?> {% elseif avatar.type == 'gravatar' %}
<i class="fas fa-globe text-xs text-green-600" title="Gravatar"></i> <i class="fas fa-globe text-xs text-green-600 dark:text-green-400" title="Gravatar"></i>
<?php else: ?> {% else %}
<i class="fas fa-user text-xs text-gray-500" title="Initials"></i> <i class="fas fa-user text-xs text-gray-500 dark:text-slate-400" title="Initials"></i>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
<h3 class="mt-4 text-base font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></h3> <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 mt-1">@<?= htmlspecialchars($user['username'] ?? '') ?></p> <p class="text-sm text-gray-500 dark:text-slate-400 mt-1">@{{ user.username|default('') }}</p>
<!-- Role Badge --> <!-- 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"> <div class="mt-3">{{ role_badge(user.role|default('user')) }}</div>
<i class="fas fa-<?= $user['role'] === 'admin' ? 'crown' : 'user' ?> mr-1.5"></i>
<?= ucfirst($user['role'] ?? 'user') ?>
</span>
<!-- Stats --> <!-- Stats -->
<div class="grid grid-cols-2 gap-3 mt-4 w-full"> <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="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">Member Since</div> <div class="text-xs text-gray-500 dark:text-slate-400">Member Since</div>
<div class="text-xs font-semibold text-gray-900 mt-0.5"> <div class="text-xs font-semibold text-gray-900 dark:text-white mt-0.5">
<?= date('M Y', strtotime($user['created_at'] ?? 'now')) ?> {{ (user.created_at|default('now'))|date('M Y') }}
</div> </div>
</div> </div>
<div class="bg-white rounded-lg p-2 border border-gray-200"> <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">Status</div> <div class="text-xs text-gray-500 dark:text-slate-400">Status</div>
<div class="text-xs font-semibold text-green-600 mt-0.5"> <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 <i class="fas fa-circle text-xs"></i> Active
</div> </div>
</div> </div>
@@ -91,13 +80,13 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
<span>Active Sessions</span> <span>Active Sessions</span>
</button> </button>
<?php if ($user['role'] !== 'admin'): ?> {% if user.role != 'admin' %}
<hr class="my-3 border-gray-200"> <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 hover:bg-red-50"> <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> <i class="fas fa-exclamation-triangle w-5 mr-3 text-sm"></i>
<span>Danger Zone</span> <span>Danger Zone</span>
</button> </button>
<?php endif; ?> {% endif %}
</nav> </nav>
</div> </div>
</div> </div>
@@ -107,38 +96,38 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
<!-- Profile Information Section --> <!-- Profile Information Section -->
<div id="section-profile" class="content-section"> <div id="section-profile" class="content-section">
<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">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50"> <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">Profile Information</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Profile Information</h3>
<p class="text-sm text-gray-600 mt-1">Update your personal details and account information</p> <p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Update your personal details and account information</p>
</div> </div>
<!-- Avatar Upload Section --> <!-- Avatar Upload Section -->
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h4 class="text-base font-semibold text-gray-900 mb-3">Profile Picture</h4> <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"> <div class="flex items-center space-x-4">
<!-- Current Avatar Display --> <!-- Current Avatar Display -->
<div class="relative"> <div class="relative">
<?php if ($avatar['type'] === 'uploaded' || $avatar['type'] === 'gravatar'): ?> {% if avatar.type == 'uploaded' or avatar.type == 'gravatar' %}
<img src="<?= htmlspecialchars($avatar['url']) ?>" <img src="{{ avatar.url }}"
alt="<?= htmlspecialchars($avatar['alt']) ?>" alt="{{ avatar.alt }}"
class="w-16 h-16 rounded-full object-cover border-2 border-gray-200" class="w-16 h-16 rounded-full object-cover border-2 border-gray-200 dark:border-slate-600"
loading="lazy"> loading="lazy">
<?php else: ?> {% 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"> <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'] ?> {{ avatar.initials }}
</div> </div>
<?php endif; ?> {% endif %}
<!-- Avatar type indicator --> <!-- 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"> <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">
<?php if ($avatar['type'] === 'uploaded'): ?> {% if avatar.type == 'uploaded' %}
<i class="fas fa-image text-xs text-blue-600" title="Uploaded avatar"></i> <i class="fas fa-image text-xs text-blue-600 dark:text-blue-400" title="Uploaded avatar"></i>
<?php elseif ($avatar['type'] === 'gravatar'): ?> {% elseif avatar.type == 'gravatar' %}
<i class="fas fa-globe text-xs text-green-600" title="Gravatar"></i> <i class="fas fa-globe text-xs text-green-600 dark:text-green-400" title="Gravatar"></i>
<?php else: ?> {% else %}
<i class="fas fa-user text-xs text-gray-500" title="Initials"></i> <i class="fas fa-user text-xs text-gray-500 dark:text-slate-400" title="Initials"></i>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
@@ -147,7 +136,7 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
<div class="space-y-2"> <div class="space-y-2">
<!-- Upload Form --> <!-- Upload Form -->
<form method="POST" action="/profile/upload-avatar" enctype="multipart/form-data" class="inline-block"> <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"> <div class="flex items-center space-x-2">
<input type="file" <input type="file"
id="avatar" id="avatar"
@@ -156,7 +145,7 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
class="hidden" class="hidden"
onchange="this.form.submit()"> onchange="this.form.submit()">
<label for="avatar" <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> <i class="fas fa-upload mr-2"></i>
Upload New Upload New
</label> </label>
@@ -164,77 +153,77 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
</form> </form>
<!-- Delete Avatar Button --> <!-- Delete Avatar Button -->
<?php if ($avatar['type'] === 'uploaded'): ?> {% if avatar.type == 'uploaded' %}
<form method="POST" action="/profile/delete-avatar" class="inline-block ml-2"> <form method="POST" action="/profile/delete-avatar" class="inline-block ml-2">
<?= csrf_field() ?> {{ csrf_field() }}
<button type="submit" <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?')"> onclick="return confirm('Are you sure you want to remove your avatar?')">
<i class="fas fa-trash mr-2"></i> <i class="fas fa-trash mr-2"></i>
Remove Remove
</button> </button>
</form> </form>
<?php endif; ?> {% endif %}
</div> </div>
<!-- Avatar Info --> <!-- Avatar Info -->
<div class="mt-2 text-xs text-gray-500"> <div class="mt-2 text-xs text-gray-500 dark:text-slate-400">
<?php if ($avatar['type'] === 'uploaded'): ?> {% if avatar.type == 'uploaded' %}
Using uploaded image Using uploaded image
<?php elseif ($avatar['type'] === 'gravatar'): ?> {% elseif avatar.type == 'gravatar' %}
Using Gravatar from <?= htmlspecialchars($user['email'] ?? '') ?> Using Gravatar from {{ user.email|default('') }}
<?php else: ?> {% else %}
Using initials (upload an image or set up Gravatar) Using initials (upload an image or set up Gravatar)
<?php endif; ?> {% endif %}
</div> </div>
<!-- Gravatar Info --> <!-- Gravatar Info -->
<?php if ($avatar['type'] !== 'gravatar' && !empty($user['email'])): ?> {% if avatar.type != 'gravatar' and user.email %}
<div class="mt-1 text-xs text-gray-400"> <div class="mt-1 text-xs text-gray-400 dark:text-slate-500">
<a href="https://gravatar.com" target="_blank" class="text-blue-600 hover:text-blue-800"> <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 Set up Gravatar for automatic avatar
</a> </a>
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
</div> </div>
<form method="POST" action="/profile/update" class="p-6"> <form method="POST" action="/profile/update" class="p-6">
<?= csrf_field() ?> {{ csrf_field() }}
<div class="space-y-5"> <div class="space-y-5">
<!-- Full Name --> <!-- Full Name -->
<div> <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 Full Name
</label> </label>
<input type="text" id="full_name" name="full_name" <input type="text" id="full_name" name="full_name"
value="<?= htmlspecialchars($user['full_name'] ?? '') ?>" value="{{ user.full_name|default('') }}"
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">
</div> </div>
<!-- Email --> <!-- Email -->
<div> <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 Email Address
</label> </label>
<input type="email" id="email" name="email" <input type="email" id="email" name="email"
value="<?= htmlspecialchars($user['email'] ?? '') ?>" value="{{ user.email|default('') }}"
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">
<?php if (!empty($user['email_verified'])): ?> {% if user.email_verified %}
<p class="text-xs text-green-600 mt-1.5"> <p class="text-xs text-green-600 dark:text-green-400 mt-1.5">
<i class="fas fa-check-circle mr-1"></i> <i class="fas fa-check-circle mr-1"></i>
Email verified Email verified
</p> </p>
<?php else: ?> {% else %}
<div class="mt-3 bg-amber-50 border border-amber-200 rounded-lg p-3"> <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 justify-between">
<div class="flex items-start"> <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> <div>
<p class="text-xs font-semibold text-amber-900">Email Not Verified</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 mt-0.5">Verify your email to unlock all features</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>
</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"> <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> </a>
</div> </div>
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
<!-- Username (Read-only) --> <!-- Username (Read-only) -->
<div> <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 Username
</label> </label>
<input type="text" id="username" name="username" <input type="text" id="username" name="username"
value="<?= htmlspecialchars($user['username'] ?? '') ?>" value="{{ user.username|default('') }}"
readonly readonly
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"> 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 mt-1">Username cannot be changed</p> <p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Username cannot be changed</p>
</div> </div>
<!-- Account Details Grid --> <!-- Account Details Grid -->
<div class="pt-4 border-t border-gray-200"> <div class="pt-4 border-t border-gray-200 dark:border-slate-700">
<h4 class="text-sm font-semibold text-gray-700 mb-3">Account Information</h4> <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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200"> <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 mb-1">Member Since</label> <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"> <p class="text-sm font-semibold text-gray-900 dark:text-white">
<?= date('F j, Y', strtotime($user['created_at'] ?? 'now')) ?> {{ (user.created_at|default('now'))|date('F j, Y') }}
</p> </p>
</div> </div>
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200"> <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 mb-1">Last Login</label> <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"> <p class="text-sm font-semibold text-gray-900 dark:text-white">
<?= $user['last_login'] ? date('M j, Y g:i A', strtotime($user['last_login'])) : 'Never' ?> {{ user.last_login ? user.last_login|date('M j, Y g:i A') : 'Never' }}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200 space-x-2"> <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 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium"> <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 Cancel
</button> </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"> <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 --> <!-- Two-Factor Authentication Section -->
<div id="section-twofactor" class="content-section hidden"> <div id="section-twofactor" class="content-section hidden">
<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">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50"> <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">Two-Factor Authentication</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Two-Factor Authentication</h3>
<p class="text-sm text-gray-600 mt-1">Add an extra layer of security to your account</p> <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>
<div class="p-6"> <div class="p-6">
<?php if ($twoFactorPolicy === 'disabled'): ?> {% if twoFactorPolicy == 'disabled' %}
<!-- 2FA Disabled by Admin --> <!-- 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"> <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> <div>
<p class="text-sm font-medium text-gray-900">Two-Factor Authentication Disabled</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 mt-1">2FA has been disabled by the administrator.</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> </div>
</div> </div>
<?php elseif (!$user['email_verified']): ?> {% elseif not user.email_verified %}
<!-- Email Not 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"> <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> <div>
<p class="text-sm font-medium text-amber-900">Email Verification Required</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 mt-1">You must verify your email address before enabling 2FA.</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> </div>
</div> </div>
<?php elseif ($twoFactorStatus['enabled']): ?> {% elseif twoFactorStatus.enabled %}
<!-- 2FA Enabled --> <!-- 2FA Enabled -->
<div class="space-y-4"> <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"> <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> <div>
<p class="text-sm font-medium text-green-900">Two-Factor Authentication Enabled</p> <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 mt-1"> <p class="text-sm text-green-700 dark:text-green-300 mt-1">
Your account is protected with 2FA since Your account is protected with 2FA since
<?= date('M j, Y', strtotime($twoFactorStatus['setup_at'])) ?>. {{ twoFactorStatus.setup_at|date('M j, Y') }}.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <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 class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-gray-900">Backup Codes</p> <p class="text-sm font-medium text-gray-900 dark:text-white">Backup Codes</p>
<p class="text-sm text-gray-600"><?= $twoFactorStatus['backup_codes_count'] ?> remaining</p> <p class="text-sm text-gray-600 dark:text-slate-400">{{ twoFactorStatus.backup_codes_count }} remaining</p>
</div> </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> </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 class="flex items-center justify-between">
<div> <div>
<p class="text-sm font-medium text-gray-900">Authenticator App</p> <p class="text-sm font-medium text-gray-900 dark:text-white">Authenticator App</p>
<p class="text-sm text-gray-600">Active</p> <p class="text-sm text-gray-600 dark:text-slate-400">Active</p>
</div> </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>
</div> </div>
<div class="flex flex-col sm:flex-row gap-3"> <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.')"> <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"> <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> <i class="fas fa-refresh mr-2"></i>
Generate New Backup Codes Generate New Backup Codes
</button> </button>
</form> </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"> <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> <i class="fas fa-ban mr-2"></i>
Disable 2FA Disable 2FA
</button> </button>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
<?php elseif ($twoFactorStatus['required']): ?> {% elseif twoFactorStatus.required %}
<!-- 2FA 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"> <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> <div>
<p class="text-sm font-medium text-red-900">Two-Factor Authentication Required</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 mt-1">You must enable 2FA to continue using your account.</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> </div>
</div> </div>
@@ -396,15 +385,15 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
Enable Two-Factor Authentication Enable Two-Factor Authentication
</a> </a>
</div> </div>
<?php else: ?> {% else %}
<!-- 2FA Optional --> <!-- 2FA Optional -->
<div class="space-y-4"> <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"> <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> <div>
<p class="text-sm font-medium text-blue-900">Enhanced Security Available</p> <p class="text-sm font-medium text-blue-900 dark:text-blue-400">Enhanced Security Available</p>
<p class="text-sm text-blue-700 mt-1"> <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. Enable two-factor authentication to add an extra layer of security to your account.
</p> </p>
</div> </div>
@@ -418,9 +407,9 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
</a> </a>
</div> </div>
<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">
<h4 class="text-sm font-medium text-gray-900 mb-2">How 2FA Works</h4> <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 space-y-1"> <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>• Generate time-based codes using an authenticator app</li>
<li>• Use backup codes if you lose access to your device</li> <li>• Use backup codes if you lose access to your device</li>
<li>• Receive email codes as an alternative method</li> <li>• Receive email codes as an alternative method</li>
@@ -428,63 +417,63 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
</ul> </ul>
</div> </div>
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Security Section --> <!-- Security Section -->
<div id="section-security" class="content-section hidden"> <div id="section-security" class="content-section hidden">
<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">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50"> <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">Security Settings</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Security Settings</h3>
<p class="text-sm text-gray-600 mt-1">Manage your password and security preferences</p> <p class="text-sm text-gray-600 dark:text-slate-400 mt-1">Manage your password and security preferences</p>
</div> </div>
<form method="POST" action="/profile/change-password" class="p-6"> <form method="POST" action="/profile/change-password" class="p-6">
<?= csrf_field() ?> {{ csrf_field() }}
<div class="space-y-4"> <div class="space-y-4">
<!-- Current Password --> <!-- Current Password -->
<div> <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 Current Password
</label> </label>
<input type="password" id="current_password" name="current_password" required <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"> placeholder="Enter your current password">
</div> </div>
<!-- New Password --> <!-- New Password -->
<div> <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 New Password
</label> </label>
<input type="password" id="new_password" name="new_password" required minlength="8" <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"> 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> </div>
<!-- Confirm New Password --> <!-- Confirm New Password -->
<div> <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 Confirm New Password
</label> </label>
<input type="password" id="new_password_confirm" name="new_password_confirm" required minlength="8" <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"> placeholder="Re-enter your new password">
</div> </div>
<!-- Password Tips --> <!-- Password Tips -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3"> <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"> <p class="text-xs text-gray-600 dark:text-slate-400">
<i class="fas fa-info-circle text-blue-500 mr-1"></i> <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. Use at least 8 characters with a mix of letters, numbers, and symbols for better security.
</p> </p>
</div> </div>
</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"> <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> <i class="fas fa-key mr-2"></i>
Update Password Update Password
@@ -496,156 +485,150 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
<!-- Active Sessions Section --> <!-- Active Sessions Section -->
<div id="section-sessions" class="content-section hidden"> <div id="section-sessions" class="content-section hidden">
<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">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50"> <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 class="flex items-center justify-between">
<div> <div>
<h3 class="text-lg font-semibold text-gray-900">Active Sessions</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">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> <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> </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"> <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"> <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> <i class="fas fa-sign-out-alt mr-1.5"></i>
Logout Others Logout Others
</button> </button>
</form> </form>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
<div class="p-6"> <div class="p-6">
<?php if (!empty($sessions)): ?> {% if sessions is not empty %}
<div class="space-y-3"> <div class="space-y-3">
<?php foreach ($sessions as $session): ?> {% for session in sessions %}
<?php {% set isCurrent = session.is_current|default(false) %}
// Display data prepared by SessionHelper in controller {% set deviceColor = isCurrent ? 'green' : 'gray' %}
$deviceIcon = $session['deviceIcon']; {% 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' %}
$browserInfo = $session['browserInfo']; <div class="flex items-start justify-between p-4 {{ bgClass }} border rounded-lg">
$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">
<div class="flex items-start space-x-3 flex-1"> <div class="flex items-start space-x-3 flex-1">
<!-- Device Icon --> <!-- Device Icon -->
<div class="w-10 h-10 bg-<?= $isCurrent ? 'green' : 'gray' ?>-100 rounded-lg flex items-center justify-center flex-shrink-0"> <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 <?= $deviceIcon ?> text-<?= $isCurrent ? 'green' : 'gray' ?>-600"></i> <i class="fas {{ session.deviceIcon }} text-{{ deviceColor }}-600 dark:text-{{ deviceColor }}-400"></i>
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<!-- Header --> <!-- Header -->
<div class="flex items-center flex-wrap gap-2"> <div class="flex items-center flex-wrap gap-2">
<?php if (!empty($session['country_code']) && $session['country_code'] !== 'xx'): ?> {% if session.country_code and session.country_code != 'xx' %}
<span class="fi fi-<?= strtolower($session['country_code']) ?> text-base"></span> <span class="fi fi-{{ session.country_code|lower }} text-base"></span>
<?php endif; ?> {% endif %}
<h4 class="text-sm font-semibold text-gray-900"> <h4 class="text-sm font-semibold text-gray-900 dark:text-white">
<?= htmlspecialchars($session['city'] ?? 'Unknown') ?>, <?= htmlspecialchars($session['country'] ?? 'Unknown') ?> {{ session.city|default('Unknown') }}, {{ session.country|default('Unknown') }}
</h4> </h4>
<?php if ($isCurrent): ?> {% if isCurrent %}
<span class="px-2 py-0.5 bg-green-500 text-white text-xs font-semibold rounded"> <span class="px-2 py-0.5 bg-green-500 text-white text-xs font-semibold rounded">
Current Current
</span> </span>
<?php endif; ?> {% endif %}
<?php if (!empty($session['has_remember_token'])): ?> {% if 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"> <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> <i class="fas fa-cookie-bite"></i>
</span> </span>
<?php endif; ?> {% endif %}
</div> </div>
<!-- Browser & OS --> <!-- 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> <i class="fas fa-globe mr-1"></i>
<?= htmlspecialchars($browserInfo) ?> {{ session.browserInfo }}
<?php if (!empty($session['user_agent'])): ?> {% if session.user_agent %}
- <?= htmlspecialchars(substr($session['user_agent'], 0, 60)) ?><?= strlen($session['user_agent']) > 60 ? '...' : '' ?> - {{ session.user_agent|slice(0, 60) }}{{ session.user_agent|length > 60 ? '...' : '' }}
<?php endif; ?> {% endif %}
</p> </p>
<!-- IP & ISP --> <!-- 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> <span>
<i class="fas fa-map-marker-alt mr-1"></i> <i class="fas fa-map-marker-alt mr-1"></i>
<?= htmlspecialchars($session['ip_address']) ?> {{ session.ip_address }}
</span> </span>
<?php if (!empty($session['isp'])): ?> {% if session.isp %}
<span> <span>
<i class="fas fa-network-wired mr-1"></i> <i class="fas fa-network-wired mr-1"></i>
<?= htmlspecialchars($session['isp']) ?> {{ session.isp }}
</span> </span>
<?php endif; ?> {% endif %}
</div> </div>
<!-- Session Age & Last Activity --> <!-- Session Age & Last Activity -->
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400 mt-1"> <div class="flex flex-wrap items-center gap-3 text-xs text-gray-400 dark:text-slate-500 mt-1">
<span title="Session started: <?= date('M j, Y H:i', strtotime($session['created_at'])) ?>"> <span title="Session started: {{ session.created_at|date('M j, Y H:i') }}">
<i class="fas fa-hourglass-start mr-1"></i> <i class="fas fa-hourglass-start mr-1"></i>
<?= $sessionAge ?> {{ session.sessionAge }}
</span> </span>
<span> <span>
<i class="fas fa-clock mr-1"></i> <i class="fas fa-clock mr-1"></i>
Active <?= $timeAgo ?> Active {{ session.timeAgo }}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<!-- Delete Button (only for non-current sessions) --> <!-- Delete Button (only for non-current sessions) -->
<?php if (!$isCurrent): ?> {% if not 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"> <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() ?> {{ 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"> <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> <i class="fas fa-times text-sm"></i>
</button> </button>
</form> </form>
<?php endif; ?> {% endif %}
</div> </div>
<?php endforeach; ?> {% endfor %}
</div> </div>
<!-- Info Box --> <!-- Info Box -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mt-4"> <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"> <p class="text-xs text-gray-600 dark:text-slate-400">
<i class="fas fa-info-circle text-blue-500 mr-1"></i> <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. If you see any suspicious sessions or don't recognize a device, logout other sessions immediately and change your password.
</p> </p>
</div> </div>
<?php else: ?> {% else %}
<div class="text-center py-8"> <div class="text-center py-8">
<i class="fas fa-laptop text-gray-300 text-4xl mb-3"></i> <i class="fas fa-laptop text-gray-300 dark:text-slate-600 text-4xl mb-3"></i>
<p class="text-sm text-gray-600">No active sessions found</p> <p class="text-sm text-gray-600 dark:text-slate-400">No active sessions found</p>
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Danger Zone Section --> <!-- Danger Zone Section -->
<?php if ($user['role'] !== 'admin'): ?> {% if user.role != 'admin' %}
<div id="section-danger" class="content-section hidden"> <div id="section-danger" class="content-section hidden">
<div class="bg-white rounded-lg border border-red-200 overflow-hidden"> <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 bg-red-50"> <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">Danger Zone</h3> <h3 class="text-lg font-semibold text-red-900 dark:text-red-400">Danger Zone</h3>
<p class="text-sm text-red-700 mt-1">Irreversible and destructive actions</p> <p class="text-sm text-red-700 dark:text-red-300 mt-1">Irreversible and destructive actions</p>
</div> </div>
<div class="p-6"> <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 items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<h4 class="text-sm font-bold text-red-900">Delete Account Permanently</h4> <h4 class="text-sm font-bold text-red-900 dark:text-red-400">Delete Account Permanently</h4>
<p class="text-sm text-red-700 mt-2"> <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. Once you delete your account, there is no going back. This will permanently delete all your profile information and account settings.
</p> </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 This action cannot be undone
</p> </p>
</div> </div>
<form id="deleteAccountForm" method="POST" action="/profile/delete" class="inline"> <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"> <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> <i class="fas fa-trash-alt mr-2"></i>
Delete Account Delete Account
@@ -656,34 +639,39 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
</div> </div>
</div> </div>
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
<style> <style>
/* Navigation Styles */
.nav-item { .nav-item {
color: #6b7280; color: #6b7280;
text-align: left; text-align: left;
} }
.dark .nav-item {
color: #94a3b8;
}
.nav-item:hover { .nav-item:hover {
background-color: #f3f4f6; background-color: #f3f4f6;
color: #1f2937; color: #1f2937;
} }
.dark .nav-item:hover {
background-color: #334155;
color: #f1f5f9;
}
.nav-item.active { .nav-item.active {
background-color: #EFF6FF; background-color: #EFF6FF;
color: #4A90E2; color: #4A90E2;
font-weight: 600; font-weight: 600;
} }
.dark .nav-item.active {
/* Content Section Animations */ background-color: rgba(74, 144, 226, 0.15);
color: #60a5fa;
}
.content-section { .content-section {
animation: fadeIn 0.3s ease-out; animation: fadeIn 0.3s ease-out;
} }
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
@@ -698,38 +686,28 @@ $avatar = \App\Helpers\AvatarHelper::getAvatar($user, 80);
<script> <script>
function showSection(section) { function showSection(section) {
// Hide all sections
document.querySelectorAll('.content-section').forEach(el => { document.querySelectorAll('.content-section').forEach(el => {
el.classList.add('hidden'); el.classList.add('hidden');
}); });
// Remove active class from all nav items
document.querySelectorAll('.nav-item').forEach(el => { document.querySelectorAll('.nav-item').forEach(el => {
el.classList.remove('active'); el.classList.remove('active');
}); });
// Show selected section
document.getElementById('section-' + section).classList.remove('hidden'); document.getElementById('section-' + section).classList.remove('hidden');
// Add active class to selected nav item
document.getElementById('nav-' + section).classList.add('active'); document.getElementById('nav-' + section).classList.add('active');
// Update URL hash
window.location.hash = section; window.location.hash = section;
// Scroll to top smoothly
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
} }
// On page load, check URL hash and show that section
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const hash = window.location.hash.substring(1); // Remove # const hash = window.location.hash.substring(1);
const validSections = ['profile', 'security', 'twofactor', 'sessions'<?php if ($user['role'] !== 'admin'): ?>, 'danger'<?php endif; ?>]; const validSections = ['profile', 'security', 'twofactor', 'sessions'{% if user.role != 'admin' %}, 'danger'{% endif %}];
if (hash && validSections.includes(hash)) { if (hash && validSections.includes(hash)) {
showSection(hash); showSection(hash);
} else { } else {
// Default to profile section
showSection('profile'); showSection('profile');
} }
}); });
@@ -754,22 +732,22 @@ function hideDisable2FAModal() {
</script> </script>
<!-- Disable 2FA Modal --> <!-- 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 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 w-96 shadow-lg rounded-md bg-white"> <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="mt-3">
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-red-100 rounded-full mb-4"> <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 text-xl"></i> <i class="fas fa-ban text-red-600 dark:text-red-400 text-xl"></i>
</div> </div>
<h3 class="text-lg font-medium text-gray-900 text-center mb-2">Disable Two-Factor Authentication</h3> <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 text-center mb-6"> <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. This will make your account less secure. Enter your 2FA code to confirm.
</p> </p>
<form id="disable2FAForm" method="POST" action="/2fa/disable" class="space-y-4"> <form id="disable2FAForm" method="POST" action="/2fa/disable" class="space-y-4">
<?= csrf_field() ?> {{ csrf_field() }}
<div> <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 Verification Code
</label> </label>
<input type="text" <input type="text"
@@ -777,9 +755,9 @@ function hideDisable2FAModal() {
name="verification_code" name="verification_code"
maxlength="8" maxlength="8"
placeholder="Enter 2FA code" 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> 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>
<div class="flex space-x-3 pt-4"> <div class="flex space-x-3 pt-4">
@@ -789,7 +767,7 @@ function hideDisable2FAModal() {
</button> </button>
<button type="button" <button type="button"
onclick="hideDisable2FAModal()" 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 Cancel
</button> </button>
</div> </div>
@@ -797,8 +775,4 @@ function hideDisable2FAModal() {
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
<?php
$content = ob_get_clean();
require __DIR__ . '/../layout/base.php';
?>

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

View File

@@ -1,53 +1,34 @@
<?php {% extends 'layout/base.twig' %}
$title = 'Tag Management';
$pageTitle = 'Tag Management';
$pageDescription = 'Manage your domain tags, colors, and organization';
$pageIcon = 'fas fa-tags';
ob_start();
// Helper function to generate sort URL {% set title = 'Tag Management' %}
function sortUrl($column, $currentSort, $currentOrder) { {% set pageTitle = 'Tag Management' %}
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc'; {% set pageDescription = 'Manage your domain tags, colors, and organization' %}
$params = $_GET; {% set pageIcon = 'fas fa-tags' %}
$params['sort'] = $column;
$params['order'] = $newOrder;
return '/tags?' . http_build_query($params);
}
// Helper function for sort icon {% set currentFilters = filters|default({ search: '', color: '', type: '', sort: 'name', order: 'asc' }) %}
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 {% block content %}
$currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sort' => 'name', 'order' => 'asc']; {# Action Buttons #}
?>
<!-- Action Buttons -->
<div class="mb-4 flex gap-2 justify-end"> <div class="mb-4 flex gap-2 justify-end">
<!-- Export Dropdown --> {# Export Dropdown #}
<div class="relative" id="exportDropdownWrapper"> <div class="relative" id="exportDropdownWrapper">
<button onclick="document.getElementById('exportDropdownMenu').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"> <button onclick="document.getElementById('exportDropdownMenu').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> <i class="fas fa-download mr-2"></i>
Export Export
<i class="fas fa-chevron-down ml-2 text-xs"></i> <i class="fas fa-chevron-down ml-2 text-xs"></i>
</button> </button>
<div id="exportDropdownMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden"> <div id="exportDropdownMenu" 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="/tags/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"> <a href="/tags/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> <i class="fas fa-file-csv text-green-600 mr-2.5"></i>
Export as CSV Export as CSV
</a> </a>
<a href="/tags/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="/tags/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> <i class="fas fa-file-code text-blue-600 mr-2.5"></i>
Export as JSON Export as JSON
</a> </a>
</div> </div>
</div> </div>
<!-- Import Button --> {# Import Button #}
<button onclick="document.getElementById('importModal').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"> <button onclick="document.getElementById('importModal').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> <i class="fas fa-upload mr-2"></i>
Import Import
@@ -58,32 +39,32 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
</button> </button>
</div> </div>
<!-- Filters & Search --> {# Filters & Search #}
<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">
<form method="GET" action="/tags" id="filter-form"> <form method="GET" action="/tags" id="filter-form">
<div class="grid grid-cols-1 md:grid-cols-4 gap-3"> <div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div> <div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search</label> <label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Search</label>
<div class="relative"> <div class="relative">
<input type="text" name="search" id="tagSearch" value="<?= htmlspecialchars($currentFilters['search'] ?? '') ?>" placeholder="Search tags..." 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"> <input type="text" name="search" id="tagSearch" value="{{ currentFilters.search|default('') }}" placeholder="Search tags..." 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 text-xs"></i> <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> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Color</label> <label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Color</label>
<select name="color" id="colorFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"> <select name="color" id="colorFilter" 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 Colors</option> <option value="">All Colors</option>
<?php foreach ($availableColors as $colorValue => $colorName): ?> {% for colorValue, colorName in availableColors %}
<option value="<?= htmlspecialchars($colorValue) ?>" <?= ($currentFilters['color'] ?? '') === $colorValue ? 'selected' : '' ?>><?= htmlspecialchars($colorName) ?></option> <option value="{{ colorValue }}" {{ (currentFilters.color|default('')) == colorValue ? 'selected' : '' }}>{{ colorName }}</option>
<?php endforeach; ?> {% endfor %}
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Type</label> <label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Type</label>
<select name="type" id="typeFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"> <select name="type" id="typeFilter" 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 Types</option> <option value="">All Types</option>
<option value="global" <?= ($currentFilters['type'] ?? '') === 'global' ? 'selected' : '' ?>>Global Tags</option> <option value="global" {{ (currentFilters.type|default('')) == 'global' ? 'selected' : '' }}>Global Tags</option>
<option value="user" <?= ($currentFilters['type'] ?? '') === 'user' ? 'selected' : '' ?>>My Tags</option> <option value="user" {{ (currentFilters.type|default('')) == 'user' ? 'selected' : '' }}>My Tags</option>
</select> </select>
</div> </div>
<div class="flex items-end space-x-2"> <div class="flex items-end space-x-2">
@@ -91,353 +72,324 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
<i class="fas fa-filter mr-2"></i> <i class="fas fa-filter mr-2"></i>
Apply Apply
</button> </button>
<a href="/tags" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium"> <a href="/tags" 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> <i class="fas fa-times"></i>
Clear Clear
</a> </a>
</div> </div>
</div> </div>
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort'] ?? 'name') ?>"> <input type="hidden" name="sort" value="{{ currentFilters.sort|default('name') }}">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order'] ?? 'asc') ?>"> <input type="hidden" name="order" value="{{ currentFilters.order|default('asc') }}">
</form> </form>
</div> </div>
<!-- Pagination Info & Per Page Selector --> {# Pagination Info & Per Page Selector #}
<div class="mb-4 flex justify-between items-center"> <div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600 dark:text-slate-400">
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?? 1 ?></span> to 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"><?= $pagination['showing_to'] ?? count($tags) ?></span> of <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_to|default(tags|length) }}</span> of
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?? count($tags) ?></span> tag(s) <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total|default(tags|length) }}</span> tag(s)
</div> </div>
<form method="GET" action="/tags" class="flex items-center gap-2"> <form method="GET" action="/tags" class="flex items-center gap-2">
<!-- Preserve current filters --> <input type="hidden" name="search" value="{{ currentFilters.search|default('') }}">
<input type="hidden" name="search" value="<?= htmlspecialchars($currentFilters['search'] ?? '') ?>"> <input type="hidden" name="color" value="{{ currentFilters.color|default('') }}">
<input type="hidden" name="color" value="<?= htmlspecialchars($currentFilters['color'] ?? '') ?>"> <input type="hidden" name="type" value="{{ currentFilters.type|default('') }}">
<input type="hidden" name="type" value="<?= htmlspecialchars($currentFilters['type'] ?? '') ?>"> <input type="hidden" name="sort" value="{{ currentFilters.sort|default('name') }}">
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort'] ?? 'name') ?>"> <input type="hidden" name="order" value="{{ currentFilters.order|default('asc') }}">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order'] ?? 'asc') ?>">
<label for="per_page" class="text-sm text-gray-600">Show:</label> <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 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary"> <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'] ?? 25) == 10 ? 'selected' : '' ?>>10</option> <option value="10" {{ (pagination.per_page|default(25)) == 10 ? 'selected' : '' }}>10</option>
<option value="25" <?= ($pagination['per_page'] ?? 25) == 25 ? 'selected' : '' ?>>25</option> <option value="25" {{ (pagination.per_page|default(25)) == 25 ? 'selected' : '' }}>25</option>
<option value="50" <?= ($pagination['per_page'] ?? 25) == 50 ? 'selected' : '' ?>>50</option> <option value="50" {{ (pagination.per_page|default(25)) == 50 ? 'selected' : '' }}>50</option>
<option value="100" <?= ($pagination['per_page'] ?? 25) == 100 ? 'selected' : '' ?>>100</option> <option value="100" {{ (pagination.per_page|default(25)) == 100 ? 'selected' : '' }}>100</option>
</select> </select>
</form> </form>
</div> </div>
<!-- Tags List --> {# Tags List #}
<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">
<!-- Bulk Actions Bar (shown when tags are selected) --> {# Bulk Actions Bar (shown when tags 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 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"> <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-3 flex-wrap">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<?php if (\Core\Auth::isAdmin()): ?> {% if auth.isAdmin %}
<button type="button" onclick="bulkTransferTags()" 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"> <button type="button" onclick="bulkTransferTags()" 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 <i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
</button> </button>
<?php endif; ?> {% endif %}
<button type="button" onclick="bulkDeleteTags()" 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"> <button type="button" onclick="bulkDeleteTags()" 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 <i class="fas fa-trash mr-1"></i> Delete Selected
</button> </button>
</div> </div>
</div> </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 <i class="fas fa-times mr-1.5"></i> Clear Selection
</button> </button>
</div> </div>
<?php if (!empty($tags)): ?> {% if tags is not empty %}
<!-- Table View (Desktop) --> {# Table View (Desktop) #}
<div class="hidden lg:block overflow-x-auto"> <div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
<thead class="bg-gray-50"> <thead class="bg-gray-50 dark:bg-slate-900">
<tr> <tr>
<th class="px-6 py-3 text-left"> <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>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"> <th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
<a href="<?= sortUrl('name', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?>" class="hover:text-primary flex items-center"> <a href="{{ sort_url('name', currentFilters.sort|default('name'), currentFilters.order|default('asc'), currentFilters) }}" class="hover:text-primary flex items-center">
Tag <?= sortIcon('name', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?> Tag {{ sort_icon('name', currentFilters.sort|default('name'), currentFilters.order|default('asc')) }}
</a> </a>
</th> </th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"> <th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
<a href="<?= sortUrl('description', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?>" class="hover:text-primary flex items-center"> <a href="{{ sort_url('description', currentFilters.sort|default('name'), currentFilters.order|default('asc'), currentFilters) }}" class="hover:text-primary flex items-center">
Description <?= sortIcon('description', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?> Description {{ sort_icon('description', currentFilters.sort|default('name'), currentFilters.order|default('asc')) }}
</a> </a>
</th> </th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"> <th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">
<a href="<?= sortUrl('usage_count', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?>" class="hover:text-primary flex items-center"> <a href="{{ sort_url('usage_count', currentFilters.sort|default('name'), currentFilters.order|default('asc'), currentFilters) }}" class="hover:text-primary flex items-center">
Usage <?= sortIcon('usage_count', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?> Usage {{ sort_icon('usage_count', currentFilters.sort|default('name'), currentFilters.order|default('asc')) }}
</a> </a>
</th> </th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</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> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
<?php foreach ($tags as $tag): ?> {% for tag in tags %}
<tr class="hover:bg-gray-50 transition-colors duration-150 tag-row"> <tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150 tag-row">
<td class="px-6 py-4"> <td class="px-6 py-4">
<input type="checkbox" class="tag-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= $tag['id'] ?>" onchange="updateBulkActions()"> <input type="checkbox" class="tag-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="{{ tag.id }}" onchange="updateBulkActions()">
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex items-center"> <div class="flex items-center">
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?>"> <span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border {{ tag.color }}">
<i class="fas fa-tag mr-1" style="font-size: 9px;"></i> <i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
<?= htmlspecialchars($tag['name']) ?> {{ tag.name }}
</span> </span>
<?php if ($tag['user_id'] === null): ?> {% if tag.user_id is null %}
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> <span class="ml-2 inline-flex items-center px-2 py-0.5 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-globe mr-1" style="font-size: 8px;"></i> <i class="fas fa-globe mr-1" style="font-size: 8px;"></i>
Global Global
</span> </span>
<?php endif; ?> {% endif %}
</div> </div>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="text-sm text-gray-900"> <div class="text-sm text-gray-900 dark:text-white">
<?= !empty($tag['description']) ? htmlspecialchars($tag['description']) : '<span class="text-gray-400 italic">No description</span>' ?> {% if tag.description is not empty %}
{{ tag.description }}
{% else %}
<span class="text-gray-400 dark:text-slate-500 italic">No description</span>
{% endif %}
</div> </div>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex items-center text-sm text-gray-500"> <div class="flex items-center text-sm text-gray-500 dark:text-slate-400">
<i class="fas fa-link mr-1"></i> <i class="fas fa-link mr-1"></i>
<?= $tag['usage_count'] ?> domain<?= $tag['usage_count'] !== 1 ? 's' : '' ?> {{ tag.usage_count }} domain{{ tag.usage_count != 1 ? 's' : '' }}
</div> </div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2"> <div class="flex items-center justify-end space-x-2">
<a href="/tags/<?= $tag['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View"> <a href="/tags/{{ tag.id }}" class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
<?php if (\Core\Auth::isAdmin()): ?> {% if auth.isAdmin %}
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag" <button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
data-tag-id="<?= (int)$tag['id'] ?>" data-tag-name="<?= htmlspecialchars($tag['name'], ENT_QUOTES, 'UTF-8') ?>"> data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
<i class="fas fa-exchange-alt"></i> <i class="fas fa-exchange-alt"></i>
</button> </button>
<?php endif; ?> {% endif %}
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?> {% if tag.user_id is null and not auth.isAdmin %}
<!-- Global tag - only admins can edit/delete --> <span class="text-xs text-gray-500 dark:text-slate-400 italic">Global tag</span>
<span class="text-xs text-gray-500 italic">Global tag</span> {% else %}
<?php else: ?> <button onclick="openEditModal({{ tag|json_encode|e('html_attr') }})"
<button onclick="openEditModal(<?= htmlspecialchars(json_encode($tag)) ?>)"
class="text-yellow-600 hover:text-yellow-800" title="Edit"> class="text-yellow-600 hover:text-yellow-800" title="Edit">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<button onclick="deleteTag(<?= $tag['id'] ?>, <?= htmlspecialchars(json_encode($tag['name'])) ?>)" <button onclick="deleteTag({{ tag.id }}, {{ tag.name|json_encode|e('html_attr') }})"
class="text-red-600 hover:text-red-800" title="Delete"> class="text-red-600 hover:text-red-800" title="Delete">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
<?php endif; ?> {% endif %}
</div> </div>
</td> </td>
</tr> </tr>
<?php endforeach; ?> {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Card View (Mobile) --> {# Card View (Mobile) #}
<div class="lg:hidden divide-y divide-gray-200"> <div class="lg:hidden divide-y divide-gray-200 dark:divide-slate-700">
<?php foreach ($tags as $tag): ?> {% for tag in tags %}
<div class="p-6 hover:bg-gray-50 transition-colors duration-150"> <div class="p-6 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<input type="checkbox" class="tag-checkbox-mobile rounded border-gray-300 text-primary focus:ring-primary mr-3" value="<?= $tag['id'] ?>" onchange="updateBulkActions()"> <input type="checkbox" class="tag-checkbox-mobile rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary mr-3" value="{{ tag.id }}" onchange="updateBulkActions()">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?>"> <span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border {{ tag.color }}">
<i class="fas fa-tag mr-1" style="font-size: 9px;"></i> <i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
<?= htmlspecialchars($tag['name']) ?> {{ tag.name }}
</span> </span>
<?php if ($tag['user_id'] === null): ?> {% if tag.user_id is null %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> <span class="inline-flex items-center px-2 py-0.5 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-globe mr-1" style="font-size: 8px;"></i> <i class="fas fa-globe mr-1" style="font-size: 8px;"></i>
Global Global
</span> </span>
<?php endif; ?> {% endif %}
</div> </div>
<?php if (!empty($tag['description'])): ?> {% if tag.description is not empty %}
<p class="text-sm text-gray-600 mb-2"><?= htmlspecialchars($tag['description']) ?></p> <p class="text-sm text-gray-600 dark:text-slate-400 mb-2">{{ tag.description }}</p>
<?php endif; ?> {% endif %}
<div class="flex items-center text-sm text-gray-500"> <div class="flex items-center text-sm text-gray-500 dark:text-slate-400">
<i class="fas fa-link mr-1"></i> <i class="fas fa-link mr-1"></i>
Used on <?= $tag['usage_count'] ?> domain<?= $tag['usage_count'] !== 1 ? 's' : '' ?> Used on {{ tag.usage_count }} domain{{ tag.usage_count != 1 ? 's' : '' }}
</div> </div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<a href="/tags/<?= $tag['id'] ?>" <a href="/tags/{{ tag.id }}"
class="text-blue-600 hover:text-blue-800" title="View"> class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
<?php if (\Core\Auth::isAdmin()): ?> {% if auth.isAdmin %}
<button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag" <button type="button" class="tag-transfer-btn text-green-600 hover:text-green-800" title="Transfer Tag"
data-tag-id="<?= (int)$tag['id'] ?>" data-tag-name="<?= htmlspecialchars($tag['name'], ENT_QUOTES, 'UTF-8') ?>"> data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.name }}">
<i class="fas fa-exchange-alt"></i> <i class="fas fa-exchange-alt"></i>
</button> </button>
<?php endif; ?> {% endif %}
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?> {% if tag.user_id is null and not auth.isAdmin %}
<!-- Global tag - only admins can edit/delete --> <span class="text-xs text-gray-500 dark:text-slate-400 italic">Global tag</span>
<span class="text-xs text-gray-500 italic">Global tag</span> {% else %}
<?php else: ?> <button onclick="openEditModal({{ tag|json_encode|e('html_attr') }})"
<button onclick="openEditModal(<?= htmlspecialchars(json_encode($tag)) ?>)"
class="text-yellow-600 hover:text-yellow-800" title="Edit"> class="text-yellow-600 hover:text-yellow-800" title="Edit">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<button onclick="deleteTag(<?= $tag['id'] ?>, <?= htmlspecialchars(json_encode($tag['name'])) ?>)" <button onclick="deleteTag({{ tag.id }}, {{ tag.name|json_encode|e('html_attr') }})"
class="text-red-600 hover:text-red-800" title="Delete"> class="text-red-600 hover:text-red-800" title="Delete">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
</div> </div>
<?php endforeach; ?> {% endfor %}
</div> </div>
<?php else: ?> {% else %}
<div class="text-center py-12 px-6"> <div class="text-center py-12 px-6">
<div class="mb-4"> <div class="mb-4">
<i class="fas fa-tags text-gray-300 text-6xl"></i> <i class="fas fa-tags text-gray-300 dark:text-slate-600 text-6xl"></i>
</div> </div>
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Tags Yet</h3> <h3 class="text-lg font-semibold text-gray-700 dark:text-slate-300 mb-1">No Tags Yet</h3>
<p class="text-sm text-gray-500 mb-4">Start organizing your domains by creating your first tag</p> <p class="text-sm text-gray-500 dark:text-slate-400 mb-4">Start organizing your domains by creating your first tag</p>
<button onclick="openCreateModal()" 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"> <button onclick="openCreateModal()" 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> <i class="fas fa-plus mr-2"></i>
<span>Create Your First Tag</span> <span>Create Your First Tag</span>
</button> </button>
</div> </div>
<?php endif; ?> {% endif %}
</div> </div>
<!-- Pagination Controls --> {# Pagination Controls #}
<?php if (($pagination['total_pages'] ?? 1) > 1): ?> {% 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="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">
<div class="text-sm text-gray-600"> Page <span class="font-semibold text-gray-900 dark:text-white">{{ currentPage }}</span> of
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?? 1 ?></span> of <span class="font-semibold text-gray-900 dark:text-white">{{ totalPages }}</span>
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?? 1 ?></span>
</div> </div>
<!-- Pagination Buttons -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<?php {% if currentPage > 1 %}
$currentPage = $pagination['current_page'] ?? 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">
$totalPages = $pagination['total_pages'] ?? 1;
// Helper function to build pagination URL
function paginationUrl($page, $filters, $perPage) {
$params = $filters;
$params['page'] = $page;
$params['per_page'] = $perPage;
return '/tags?' . http_build_query($params);
}
?>
<!-- First Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl(1, $currentFilters ?? [], $pagination['per_page'] ?? 25) ?>" 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> <i class="fas fa-angle-double-left"></i>
</a> </a>
<?php endif; ?> <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">
<!-- Previous Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl($currentPage - 1, $currentFilters ?? [], $pagination['per_page'] ?? 25) ?>" 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 <i class="fas fa-angle-left"></i> Previous
</a> </a>
<?php endif; ?> {% endif %}
<!-- Page Numbers --> {% if startPage > 1 %}
<?php <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>
$range = 2; // Show 2 pages on each side of current page {% if startPage > 2 %}
$start = max(1, $currentPage - $range); <span class="px-2 text-gray-500 dark:text-slate-400">...</span>
$end = min($totalPages, $currentPage + $range); {% endif %}
{% endif %}
// Show first page + ellipsis if needed {% for i in startPage..endPage %}
if ($start > 1) { {% if i == currentPage %}
echo '<a href="' . paginationUrl(1, $currentFilters ?? [], $pagination['per_page'] ?? 25) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>'; <span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">{{ i }}</span>
if ($start > 2) { {% else %}
echo '<span class="px-2 text-gray-500">...</span>'; <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 %}
// Page numbers {% if endPage < totalPages %}
for ($i = $start; $i <= $end; $i++) { {% if endPage < totalPages - 1 %}
if ($i == $currentPage) { <span class="px-2 text-gray-500 dark:text-slate-400">...</span>
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>'; {% endif %}
} else { <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>
echo '<a href="' . paginationUrl($i, $currentFilters ?? [], $pagination['per_page'] ?? 25) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>'; {% endif %}
}
}
// Show last page + ellipsis if needed {% if currentPage < totalPages %}
if ($end < $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">
if ($end < $totalPages - 1) {
echo '<span class="px-2 text-gray-500">...</span>';
}
echo '<a href="' . paginationUrl($totalPages, $currentFilters ?? [], $pagination['per_page'] ?? 25) . '" 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) ?>" 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> Next <i class="fas fa-angle-right"></i>
</a> </a>
<?php 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">
<!-- Last Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($totalPages, $currentFilters ?? [], $pagination['per_page'] ?? 25) ?>" 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> <i class="fas fa-angle-double-right"></i>
</a> </a>
<?php endif; ?> {% endif %}
</div> </div>
</div> </div>
<?php endif; ?> {% endif %}
<!-- Create Tag Modal --> {# Create Tag Modal #}
<div id="createModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50"> <div id="createModal" 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="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full"> <div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full">
<form method="POST" action="/tags/create"> <form method="POST" action="/tags/create">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h3 class="text-lg font-medium text-gray-900">Create New Tag</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-white">Create New Tag</h3>
</div> </div>
<div class="px-6 py-4 space-y-4"> <div class="px-6 py-4 space-y-4">
<div> <div>
<label for="create_name" class="block text-sm font-medium text-gray-700 mb-1">Tag Name</label> <label for="create_name" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Tag Name</label>
<input type="text" id="create_name" name="name" required <input type="text" id="create_name" name="name" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 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-700 text-gray-900 dark:text-white"
placeholder="e.g., production, staging"> placeholder="e.g., production, staging">
<p class="text-xs text-gray-500 mt-1">Use only letters, numbers, and hyphens</p> <p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Use only letters, numbers, and hyphens</p>
</div> </div>
<div> <div>
<label for="create_color" class="block text-sm font-medium text-gray-700 mb-1">Color</label> <label for="create_color" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Color</label>
<select id="create_color" name="color" <select id="create_color" name="color"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> 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-700 text-gray-900 dark:text-white">
<?php foreach ($availableColors as $colorValue => $colorName): ?> {% for colorValue, colorName in availableColors %}
<option value="<?= htmlspecialchars($colorValue) ?>"><?= htmlspecialchars($colorName) ?></option> <option value="{{ colorValue }}">{{ colorName }}</option>
<?php endforeach; ?> {% endfor %}
</select> </select>
</div> </div>
<div> <div>
<label for="create_description" class="block text-sm font-medium text-gray-700 mb-1">Description (Optional)</label> <label for="create_description" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Description (Optional)</label>
<textarea id="create_description" name="description" rows="3" <textarea id="create_description" name="description" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 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-700 text-gray-900 dark:text-white"
placeholder="Describe what this tag is used for"></textarea> placeholder="Describe what this tag is used for"></textarea>
</div> </div>
</div> </div>
<div class="px-6 py-4 bg-gray-50 flex justify-end space-x-3"> <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="closeCreateModal()" <button type="button" onclick="closeCreateModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"> class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-600">
Cancel Cancel
</button> </button>
<button type="submit" <button type="submit"
@@ -446,48 +398,48 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
</button> </button>
</div> </div>
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- Edit Tag Modal --> {# Edit Tag Modal #}
<div id="editModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50"> <div id="editModal" 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="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full"> <div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-md w-full">
<form method="POST" action="/tags/update"> <form method="POST" action="/tags/update">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h3 class="text-lg font-medium text-gray-900">Edit Tag</h3> <h3 class="text-lg font-medium text-gray-900 dark:text-white">Edit Tag</h3>
</div> </div>
<div class="px-6 py-4 space-y-4"> <div class="px-6 py-4 space-y-4">
<div> <div>
<label for="edit_name" class="block text-sm font-medium text-gray-700 mb-1">Tag Name</label> <label for="edit_name" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Tag Name</label>
<input type="text" id="edit_name" name="name" required <input type="text" id="edit_name" name="name" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> 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-700 text-gray-900 dark:text-white">
</div> </div>
<div> <div>
<label for="edit_color" class="block text-sm font-medium text-gray-700 mb-1">Color</label> <label for="edit_color" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Color</label>
<select id="edit_color" name="color" <select id="edit_color" name="color"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> 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-700 text-gray-900 dark:text-white">
<?php foreach ($availableColors as $colorValue => $colorName): ?> {% for colorValue, colorName in availableColors %}
<option value="<?= htmlspecialchars($colorValue) ?>"><?= htmlspecialchars($colorName) ?></option> <option value="{{ colorValue }}">{{ colorName }}</option>
<?php endforeach; ?> {% endfor %}
</select> </select>
</div> </div>
<div> <div>
<label for="edit_description" class="block text-sm font-medium text-gray-700 mb-1">Description (Optional)</label> <label for="edit_description" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">Description (Optional)</label>
<textarea id="edit_description" name="description" rows="3" <textarea id="edit_description" name="description" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea> 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-700 text-gray-900 dark:text-white"></textarea>
</div> </div>
</div> </div>
<div class="px-6 py-4 bg-gray-50 flex justify-end space-x-3"> <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="closeEditModal()" <button type="button" onclick="closeEditModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"> class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-slate-300 bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-600">
Cancel Cancel
</button> </button>
<button type="submit" <button type="submit"
@@ -497,60 +449,60 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
</div> </div>
<input type="hidden" name="id" id="edit_id"> <input type="hidden" name="id" id="edit_id">
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<!-- Import Modal --> {# Import Modal #}
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"> <div id="importModal" 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="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 flex items-center justify-between"> <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"> <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="fas fa-upload text-primary mr-2"></i>Import Tags <i class="fas fa-upload text-primary mr-2"></i>Import Tags
</h3> </h3>
<button onclick="document.getElementById('importModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600"> <button onclick="document.getElementById('importModal').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> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
<form method="POST" action="/tags/import" enctype="multipart/form-data" id="tagImportForm"> <form method="POST" action="/tags/import" enctype="multipart/form-data" id="tagImportForm">
<?= csrf_field() ?> {{ csrf_field() }}
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<!-- Drag & Drop Zone --> {# Drag & Drop Zone #}
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Select File</label> <label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1.5">Select File</label>
<div id="tagDropzone" 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"> <div id="tagDropzone" 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="tagFileInput" <input type="file" name="import_file" accept=".csv,.json" required id="tagFileInput"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"> class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<div id="tagDropzoneContent"> <div id="tagDropzoneContent">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i> <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 font-medium">Drag & drop your file here</p> <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 my-1">or</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"> <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 <i class="fas fa-folder-open mr-1.5"></i>Browse Files
</span> </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>
<div id="tagDropzoneFile" class="hidden"> <div id="tagDropzoneFile" class="hidden">
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i> <i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
<p class="text-sm font-medium text-gray-700" id="tagFileName"></p> <p class="text-sm font-medium text-gray-700 dark:text-slate-300" id="tagFileName"></p>
<p class="text-xs text-gray-400" id="tagFileSize"></p> <p class="text-xs text-gray-400 dark:text-slate-500" id="tagFileSize"></p>
<button type="button" id="tagFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium"> <button type="button" id="tagFileRemove" 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 <i class="fas fa-trash-alt mr-1"></i>Remove
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3"> <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 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-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">CSV columns: <code class="bg-white px-1 rounded">name, color, description</code></p> <p class="text-xs text-gray-600 dark:text-slate-400">CSV columns: <code class="bg-white dark:bg-slate-700 px-1 rounded">name, color, description</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-600 dark:text-slate-400 mt-0.5">JSON: array of objects with same fields</p>
<p class="text-xs text-gray-500 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p> <p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</p>
</div> </div>
</div> </div>
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-2 rounded-b-lg"> <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('importModal').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"> <button type="button" onclick="document.getElementById('importModal').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 Cancel
</button> </button>
<button type="submit" id="tagImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors"> <button type="submit" id="tagImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
@@ -562,7 +514,6 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
</div> </div>
<script> <script>
// Multi-select functionality
function toggleSelectAll(checkbox) { function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.tag-checkbox, .tag-checkbox-mobile'); const checkboxes = document.querySelectorAll('.tag-checkbox, .tag-checkbox-mobile');
checkboxes.forEach(cb => { checkboxes.forEach(cb => {
@@ -577,7 +528,6 @@ function updateBulkActions() {
const selectedCount = document.getElementById('selected-count'); const selectedCount = document.getElementById('selected-count');
const selectAllCheckbox = document.getElementById('select-all'); const selectAllCheckbox = document.getElementById('select-all');
// Get unique tag IDs (avoid counting both desktop and mobile checkboxes)
const uniqueIds = new Set(Array.from(checkboxes).map(cb => cb.value)); const uniqueIds = new Set(Array.from(checkboxes).map(cb => cb.value));
const count = uniqueIds.size; const count = uniqueIds.size;
@@ -588,8 +538,6 @@ function updateBulkActions() {
bulkActions.classList.add('hidden'); bulkActions.classList.add('hidden');
} }
// Update select all checkbox state
// Only count desktop checkboxes to avoid double counting
const allCheckboxes = document.querySelectorAll('.tag-checkbox'); const allCheckboxes = document.querySelectorAll('.tag-checkbox');
const checkedDesktopBoxes = document.querySelectorAll('.tag-checkbox:checked'); const checkedDesktopBoxes = document.querySelectorAll('.tag-checkbox:checked');
if (selectAllCheckbox) { if (selectAllCheckbox) {
@@ -609,7 +557,6 @@ function clearSelection() {
function getSelectedIds() { function getSelectedIds() {
const checkboxes = document.querySelectorAll('.tag-checkbox:checked, .tag-checkbox-mobile:checked'); const checkboxes = document.querySelectorAll('.tag-checkbox:checked, .tag-checkbox-mobile:checked');
// Return unique IDs only (avoid duplicates from desktop and mobile views)
const ids = Array.from(checkboxes).map(cb => cb.value); const ids = Array.from(checkboxes).map(cb => cb.value);
return [...new Set(ids)]; return [...new Set(ids)];
} }
@@ -626,11 +573,10 @@ function bulkDeleteTags() {
form.method = 'POST'; form.method = 'POST';
form.action = '/tags/bulk-delete'; form.action = '/tags/bulk-delete';
// Add CSRF token
const csrfInput = document.createElement('input'); const csrfInput = document.createElement('input');
csrfInput.type = 'hidden'; csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token'; csrfInput.name = 'csrf_token';
csrfInput.value = '<?= csrf_token() ?>'; csrfInput.value = '{{ csrf_token() }}';
form.appendChild(csrfInput); form.appendChild(csrfInput);
ids.forEach(id => { ids.forEach(id => {
@@ -646,7 +592,7 @@ function bulkDeleteTags() {
} }
function transferTag(tagId, tagName) { function transferTag(tagId, tagName) {
const users = <?= json_encode($users ?? []) ?>; const users = {{ users|default([])|json_encode|raw }};
if (users.length === 0) { if (users.length === 0) {
alert('No users available for transfer'); alert('No users available for transfer');
return; return;
@@ -658,24 +604,24 @@ function transferTag(tagId, tagName) {
const modal = document.createElement('div'); 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.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = ` modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6"> <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 mb-1">Transfer Tag</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Tag</h3>
<p class="text-sm text-gray-600 mb-4">Transfer tag "${escapeHtml(tagName)}" to another user.</p> <p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer tag "${escapeHtml(tagName)}" to another user.</p>
<form method="POST" action="/tags/transfer"> <form method="POST" action="/tags/transfer">
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="tag_id" value="${tagId}"> <input type="hidden" name="tag_id" value="${tagId}">
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label> <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 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"> <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> <option value="">Select User</option>
${userOptions} ${userOptions}
</select> </select>
</div> </div>
<div class="flex justify-end gap-3"> <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"> <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 Cancel
</button> </button>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium"> <button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
@@ -688,7 +634,6 @@ function transferTag(tagId, tagName) {
document.body.appendChild(modal); document.body.appendChild(modal);
} }
// Delegate click for table/card Transfer buttons (avoids onclick quote issues)
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const btn = e.target.closest('.tag-transfer-btn'); const btn = e.target.closest('.tag-transfer-btn');
if (btn) { if (btn) {
@@ -704,7 +649,7 @@ function bulkTransferTags() {
return; return;
} }
const users = <?= json_encode($users ?? []) ?>; const users = {{ users|default([])|json_encode|raw }};
if (users.length === 0) { if (users.length === 0) {
alert('No users available for transfer'); alert('No users available for transfer');
return; return;
@@ -717,24 +662,24 @@ function bulkTransferTags() {
const modal = document.createElement('div'); 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.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
modal.innerHTML = ` modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6"> <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 mb-1">Transfer Tags</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Transfer Tags</h3>
<p class="text-sm text-gray-600 mb-4">Transfer ${ids.length} selected tag(s) to another user.</p> <p class="text-sm text-gray-600 dark:text-slate-400 mb-4">Transfer ${ids.length} selected tag(s) to another user.</p>
<form method="POST" action="/tags/bulk-transfer"> <form method="POST" action="/tags/bulk-transfer">
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
${ids.map(id => `<input type="hidden" name="tag_ids[]" value="${id}">`).join('')} ${ids.map(id => `<input type="hidden" name="tag_ids[]" value="${id}">`).join('')}
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label> <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 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"> <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> <option value="">Select User</option>
${userOptions} ${userOptions}
</select> </select>
</div> </div>
<div class="flex justify-end gap-3"> <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"> <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 Cancel
</button> </button>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium"> <button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
@@ -786,7 +731,7 @@ function deleteTag(id, name) {
const csrfInput = document.createElement('input'); const csrfInput = document.createElement('input');
csrfInput.type = 'hidden'; csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token'; csrfInput.name = 'csrf_token';
csrfInput.value = '<?= csrf_token() ?>'; csrfInput.value = '{{ csrf_token() }}';
form.appendChild(csrfInput); form.appendChild(csrfInput);
document.body.appendChild(form); document.body.appendChild(form);
@@ -794,7 +739,6 @@ function deleteTag(id, name) {
} }
} }
// Close modals on escape key
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeCreateModal(); closeCreateModal();
@@ -802,7 +746,6 @@ document.addEventListener('keydown', function(e) {
} }
}); });
// Close modals on backdrop click
document.getElementById('createModal').addEventListener('click', function(e) { document.getElementById('createModal').addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) {
closeCreateModal(); closeCreateModal();
@@ -821,7 +764,6 @@ document.getElementById('importModal').addEventListener('click', function(e) {
} }
}); });
// Close export dropdown when clicking outside
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
const wrapper = document.getElementById('exportDropdownWrapper'); const wrapper = document.getElementById('exportDropdownWrapper');
if (wrapper && !wrapper.contains(e.target)) { if (wrapper && !wrapper.contains(e.target)) {
@@ -829,7 +771,6 @@ document.addEventListener('click', function(e) {
} }
}); });
// --- Import drag-and-drop & loading ---
(function() { (function() {
const dropzone = document.getElementById('tagDropzone'); const dropzone = document.getElementById('tagDropzone');
const fileInput = document.getElementById('tagFileInput'); const fileInput = document.getElementById('tagFileInput');
@@ -907,8 +848,4 @@ document.addEventListener('click', function(e) {
}); });
})(); })();
</script> </script>
{% endblock %}
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

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 {% extends 'layout/base.twig' %}
$title = $title ?? 'Import Progress';
$pageTitle = $title; {% set title = title|default('Import Progress') %}
$pageDescription = 'Progressive data import with real-time progress'; {% set pageTitle = title %}
$pageIcon = 'fas fa-tasks'; {% set pageDescription = 'Progressive data import with real-time progress' %}
ob_start(); {% set pageIcon = 'fas fa-tasks' %}
?>
{% block content %}
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<!-- Header --> {# Header #}
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900"><?= htmlspecialchars($title) ?></h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ title }}</h1>
<p class="text-gray-600 mt-1"> <p class="text-gray-600 dark:text-slate-400 mt-1">
<?php {% set descriptions = {
$descriptions = [ tld_list: 'Importing complete TLD list from IANA',
'tld_list' => 'Importing complete TLD list from IANA', rdap: 'Importing RDAP servers for existing TLDs',
'rdap' => 'Importing RDAP servers for existing TLDs', whois: 'Importing WHOIS & Registry data via RDAP API (with HTML fallback)',
'whois' => 'Importing WHOIS & Registry data via RDAP API (with HTML fallback)', check_updates: 'Checking for IANA updates',
'check_updates' => 'Checking for IANA updates', complete_workflow: 'Complete TLD import workflow (TLD List → RDAP → WHOIS & Registry Data)'
'complete_workflow' => 'Complete TLD import workflow (TLD List → RDAP → WHOIS & Registry Data)' } %}
]; {{ descriptions[import_type]|default('Processing import') }}
echo htmlspecialchars($descriptions[$import_type] ?? 'Processing import');
?>
</p> </p>
</div> </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> <i class="fas fa-arrow-left mr-2"></i>
Back to TLD Registry Back to TLD Registry
</a> </a>
</div> </div>
</div> </div>
<!-- Progress Card --> {# Progress Card #}
<div class="bg-white rounded-lg border border-gray-200 p-6 mb-6"> <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"> <div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Import Status</h2> <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 text-yellow-800"> <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> <i class="fas fa-clock mr-2"></i>
<span id="status-text">Starting...</span> <span id="status-text">Starting...</span>
</div> </div>
</div> </div>
<!-- Progress Bar --> {# Progress Bar #}
<div class="mb-4"> <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="progress-text">0 of 0 items processed</span>
<span id="percentage-text">0%</span> <span id="percentage-text">0%</span>
</div> </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 id="progress-bar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div> </div>
</div> </div>
<!-- Step Progress (for complete workflow) --> {# Step Progress (for complete workflow) #}
<div id="step-progress" class="mb-4" style="display: none;"> <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="grid grid-cols-3 gap-2">
<div class="step-item bg-gray-100 rounded-lg p-2 text-center"> <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">Step 1</div> <div class="text-xs font-medium text-gray-600 dark:text-slate-400">Step 1</div>
<div class="text-xs text-gray-500">TLD List</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">Pending</div> <div id="step-1-status" class="text-xs text-gray-400 dark:text-slate-500">Pending</div>
</div> </div>
<div class="step-item bg-gray-100 rounded-lg p-2 text-center"> <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">Step 2</div> <div class="text-xs font-medium text-gray-600 dark:text-slate-400">Step 2</div>
<div class="text-xs text-gray-500">RDAP</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">Pending</div> <div id="step-2-status" class="text-xs text-gray-400 dark:text-slate-500">Pending</div>
</div> </div>
<div class="step-item bg-gray-100 rounded-lg p-2 text-center"> <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">Step 3</div> <div class="text-xs font-medium text-gray-600 dark:text-slate-400">Step 3</div>
<div class="text-xs text-gray-500">WHOIS & Registry</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">Pending</div> <div id="step-3-status" class="text-xs text-gray-400 dark:text-slate-500">Pending</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Statistics --> {# Statistics #}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4"> <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="flex items-center">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center mr-3"> <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"></i> <i class="fas fa-list text-blue-600 dark:text-blue-400"></i>
</div> </div>
<div> <div>
<p class="text-sm text-gray-500">Total</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">0</p> <p id="total-count" class="text-xl font-semibold text-gray-900 dark:text-white">0</p>
</div> </div>
</div> </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="flex items-center">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mr-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-check text-green-600"></i> <i class="fas fa-check text-green-600 dark:text-green-400"></i>
</div> </div>
<div> <div>
<p class="text-sm text-gray-500">Processed</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">0</p> <p id="processed-count" class="text-xl font-semibold text-gray-900 dark:text-white">0</p>
</div> </div>
</div> </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="flex items-center">
<div class="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center mr-3"> <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"></i> <i class="fas fa-times text-red-600 dark:text-red-400"></i>
</div> </div>
<div> <div>
<p class="text-sm text-gray-500">Failed</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">0</p> <p id="failed-count" class="text-xl font-semibold text-gray-900 dark:text-white">0</p>
</div> </div>
</div> </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="flex items-center">
<div class="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center mr-3"> <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"></i> <i class="fas fa-hourglass-half text-orange-600 dark:text-orange-400"></i>
</div> </div>
<div> <div>
<p class="text-sm text-gray-500">Remaining</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">0</p> <p id="remaining-count" class="text-xl font-semibold text-gray-900 dark:text-white">0</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Log Output --> {# Log Output #}
<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">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Import Log</h3> <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 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 class="text-gray-500">Initializing import process...</div>
</div> </div>
@@ -137,13 +136,12 @@ ob_start();
</div> </div>
<script> <script>
let logId = <?= json_encode($log_id) ?>; let logId = {{ log_id|json_encode|raw }};
let importType = <?= json_encode($import_type) ?>; let importType = {{ import_type|json_encode|raw }};
let isComplete = false; let isComplete = false;
let totalProcessed = 0; let totalProcessed = 0;
let totalFailed = 0; let totalFailed = 0;
// Show step progress for complete workflow
if (importType === 'complete_workflow') { if (importType === 'complete_workflow') {
document.getElementById('step-progress').style.display = 'block'; document.getElementById('step-progress').style.display = 'block';
} }
@@ -167,13 +165,11 @@ function updateProgress(data) {
const failed = data.failed || 0; const failed = data.failed || 0;
const remaining = data.remaining || 0; const remaining = data.remaining || 0;
// Update counts (use absolute values, not cumulative)
document.getElementById('total-count').textContent = total; document.getElementById('total-count').textContent = total;
document.getElementById('processed-count').textContent = processed; document.getElementById('processed-count').textContent = processed;
document.getElementById('failed-count').textContent = failed; document.getElementById('failed-count').textContent = failed;
document.getElementById('remaining-count').textContent = remaining; document.getElementById('remaining-count').textContent = remaining;
// Update progress bar
const totalToProcess = processed + remaining; const totalToProcess = processed + remaining;
const percentage = totalToProcess > 0 ? Math.round((processed / totalToProcess) * 100) : 0; const percentage = totalToProcess > 0 ? Math.round((processed / totalToProcess) * 100) : 0;
@@ -181,36 +177,32 @@ function updateProgress(data) {
document.getElementById('progress-text').textContent = `${processed} of ${totalToProcess} items processed`; document.getElementById('progress-text').textContent = `${processed} of ${totalToProcess} items processed`;
document.getElementById('percentage-text').textContent = percentage + '%'; document.getElementById('percentage-text').textContent = percentage + '%';
// Update step progress for complete workflow
if (importType === 'complete_workflow' && data.message) { if (importType === 'complete_workflow' && data.message) {
updateStepProgress(data.message, processed, total); updateStepProgress(data.message, processed, total);
} }
// Update status
const statusBadge = document.getElementById('status-badge'); const statusBadge = document.getElementById('status-badge');
const statusText = document.getElementById('status-text'); const statusText = document.getElementById('status-text');
if (data.status === 'complete') { 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'; statusText.innerHTML = '<i class="fas fa-check mr-2"></i>Complete';
isComplete = true; isComplete = true;
// Show the actual completion message from API
const completionMessage = data.message || 'Import completed successfully!'; const completionMessage = data.message || 'Import completed successfully!';
addLogMessage(completionMessage, 'success'); addLogMessage(completionMessage, 'success');
// Mark all steps as completed for complete workflow
if (importType === 'complete_workflow') { if (importType === 'complete_workflow') {
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
updateStepStatus(i, 'completed'); updateStepStatus(i, 'completed');
} }
} }
} else if (data.status === 'in_progress') { } 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'; statusText.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>In Progress';
addLogMessage(data.message || 'Processing batch...', 'info'); addLogMessage(data.message || 'Processing batch...', 'info');
} else if (data.status === 'error') { } 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'; statusText.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>Error';
addLogMessage(data.message || 'An error occurred', 'error'); addLogMessage(data.message || 'An error occurred', 'error');
isComplete = true; isComplete = true;
@@ -224,7 +216,6 @@ function checkProgress() {
fetch(`/tld-registry/api/import-progress?log_id=${logId}`) fetch(`/tld-registry/api/import-progress?log_id=${logId}`)
.then(response => { .then(response => {
// Check if response is actually JSON
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) { if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => { return response.text().then(text => {
@@ -246,13 +237,13 @@ function checkProgress() {
updateProgress(data); updateProgress(data);
if (data.status !== 'complete' && data.status !== 'error') { if (data.status !== 'complete' && data.status !== 'error') {
setTimeout(checkProgress, 2000); // Check again in 2 seconds setTimeout(checkProgress, 2000);
} }
}) })
.catch(error => { .catch(error => {
if (error.message.includes('Gateway Timeout') || error.message.includes('timeout')) { if (error.message.includes('Gateway Timeout') || error.message.includes('timeout')) {
addLogMessage('Gateway timeout detected. Retrying in 5 seconds...', 'warning'); addLogMessage('Gateway timeout detected. Retrying in 5 seconds...', 'warning');
setTimeout(checkProgress, 5000); // Retry after 5 seconds setTimeout(checkProgress, 5000);
} else { } else {
addLogMessage('Network error: ' + error.message, 'error'); addLogMessage('Network error: ' + error.message, 'error');
isComplete = true; isComplete = true;
@@ -261,33 +252,26 @@ function checkProgress() {
} }
function updateStepProgress(message, currentStep, totalSteps) { function updateStepProgress(message, currentStep, totalSteps) {
// Extract step number from message (handle both /3 and /4 formats)
const stepMatch = message.match(/Step (\d+)\/(\d+)/); const stepMatch = message.match(/Step (\d+)\/(\d+)/);
if (stepMatch) { if (stepMatch) {
const stepNumber = parseInt(stepMatch[1]); const stepNumber = parseInt(stepMatch[1]);
const totalSteps = parseInt(stepMatch[2]); const totalSteps = parseInt(stepMatch[2]);
// Check if this step is completed
const isCompleted = message.toLowerCase().includes('completed'); const isCompleted = message.toLowerCase().includes('completed');
if (isCompleted) { if (isCompleted) {
// Mark all steps up to and including this one as completed
for (let i = 1; i <= stepNumber; i++) { for (let i = 1; i <= stepNumber; i++) {
updateStepStatus(i, 'completed'); updateStepStatus(i, 'completed');
} }
// Mark next step as in progress if not the last step
if (stepNumber < totalSteps) { if (stepNumber < totalSteps) {
updateStepStatus(stepNumber + 1, 'in_progress'); updateStepStatus(stepNumber + 1, 'in_progress');
} }
} else { } else {
// Step is in progress
// Mark previous steps as completed
for (let i = 1; i < stepNumber; i++) { for (let i = 1; i < stepNumber; i++) {
updateStepStatus(i, 'completed'); updateStepStatus(i, 'completed');
} }
// Mark current step as in progress
updateStepStatus(stepNumber, 'in_progress'); updateStepStatus(stepNumber, 'in_progress');
} }
} }
@@ -299,24 +283,20 @@ function updateStepStatus(stepNumber, status) {
if (status === 'completed') { if (status === 'completed') {
stepElement.textContent = 'Completed'; stepElement.textContent = 'Completed';
stepElement.className = 'text-xs text-green-600'; stepElement.className = 'text-xs text-green-600 dark:text-green-400';
stepItem.className = 'step-item bg-green-100 rounded-lg p-2 text-center'; stepItem.className = 'step-item bg-green-100 dark:bg-green-500/10 rounded-lg p-2 text-center';
} else if (status === 'in_progress') { } else if (status === 'in_progress') {
stepElement.textContent = 'In Progress'; stepElement.textContent = 'In Progress';
stepElement.className = 'text-xs text-blue-600'; stepElement.className = 'text-xs text-blue-600 dark:text-blue-400';
stepItem.className = 'step-item bg-blue-100 rounded-lg p-2 text-center'; stepItem.className = 'step-item bg-blue-100 dark:bg-blue-500/10 rounded-lg p-2 text-center';
} else if (status === 'failed') { } else if (status === 'failed') {
stepElement.textContent = 'Failed'; stepElement.textContent = 'Failed';
stepElement.className = 'text-xs text-red-600'; stepElement.className = 'text-xs text-red-600 dark:text-red-400';
stepItem.className = 'step-item bg-red-100 rounded-lg p-2 text-center'; stepItem.className = 'step-item bg-red-100 dark:bg-red-500/10 rounded-lg p-2 text-center';
} }
} }
// Start checking progress
checkProgress(); checkProgress();
</script> </script>
<?php {% endblock %}
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

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,29 +1,29 @@
<?php {% extends 'layout/base.twig' %}
$title = 'Create User';
$pageTitle = 'Create User';
$pageDescription = 'Add a new user to the system';
$pageIcon = 'fas fa-user-plus';
ob_start();
?>
{% 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="max-w-3xl 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">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 flex items-center"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user text-gray-400 mr-2 text-sm"></i> <i class="fas fa-user text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
User Information User Information
</h2> </h2>
</div> </div>
<div class="p-6"> <div class="p-6">
<form method="POST" action="/users/store" class="space-y-5"> <form method="POST" action="/users/store" class="space-y-5">
<?= csrf_field() ?> {{ csrf_field() }}
<!-- Name & Username Row --> <!-- Name & Username Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- Full Name --> <!-- Full Name -->
<div> <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> Full Name <span class="text-red-500">*</span>
</label> </label>
<input type="text" <input type="text"
@@ -31,20 +31,20 @@ ob_start();
name="full_name" name="full_name"
required required
autofocus 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"> 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 The user's display name
</p> </p>
</div> </div>
<!-- Username --> <!-- Username -->
<div> <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> Username <span class="text-red-500">*</span>
</label> </label>
<div class="relative"> <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> <i class="fas fa-at text-sm"></i>
</span> </span>
<input type="text" <input type="text"
@@ -52,10 +52,10 @@ ob_start();
name="username" name="username"
required required
pattern="[a-zA-Z0-9_]+" 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"> placeholder="johndoe">
</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">
Letters, numbers, and underscores only Letters, numbers, and underscores only
</p> </p>
</div> </div>
@@ -65,62 +65,62 @@ ob_start();
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- Email --> <!-- Email -->
<div> <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> Email Address <span class="text-red-500">*</span>
</label> </label>
<div class="relative"> <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> <i class="fas fa-envelope text-sm"></i>
</span> </span>
<input type="email" <input type="email"
id="email" id="email"
name="email" name="email"
required 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"> placeholder="john@example.com">
</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">
Used for login and notifications Used for login and notifications
</p> </p>
</div> </div>
<!-- Role --> <!-- Role -->
<div> <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> Role <span class="text-red-500">*</span>
</label> </label>
<div class="relative"> <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> <i class="fas fa-shield-alt text-sm"></i>
</span> </span>
<select id="role" <select id="role"
name="role" name="role"
required 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="user">User</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </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> <i class="fas fa-chevron-down text-xs"></i>
</span> </span>
</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">
Admins have full system access Admins have full system access
</p> </p>
</div> </div>
</div> </div>
<!-- Password Section --> <!-- Password Section -->
<div class="border-t border-gray-200 pt-5 mt-5"> <div class="border-t border-gray-200 dark:border-slate-700 pt-5 mt-5">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center"> <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 mr-2"></i> <i class="fas fa-lock text-gray-400 dark:text-slate-500 mr-2"></i>
Password Password
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- Password --> <!-- Password -->
<div> <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> Password <span class="text-red-500">*</span>
</label> </label>
<div class="relative"> <div class="relative">
@@ -129,22 +129,22 @@ ob_start();
name="password" name="password"
required required
minlength="8" 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="••••••••"> placeholder="••••••••">
<button type="button" <button type="button"
onclick="togglePassword('password')" 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> <i class="fas fa-eye text-sm" id="password-toggle-icon"></i>
</button> </button>
</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">
Minimum 8 characters Minimum 8 characters
</p> </p>
</div> </div>
<!-- Confirm Password --> <!-- Confirm Password -->
<div> <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> Confirm Password <span class="text-red-500">*</span>
</label> </label>
<div class="relative"> <div class="relative">
@@ -153,15 +153,15 @@ ob_start();
name="password_confirm" name="password_confirm"
required required
minlength="8" 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="••••••••"> placeholder="••••••••">
<button type="button" <button type="button"
onclick="togglePassword('password_confirm')" 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> <i class="fas fa-eye text-sm" id="password_confirm-toggle-icon"></i>
</button> </button>
</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">
Re-enter the password to confirm Re-enter the password to confirm
</p> </p>
</div> </div>
@@ -176,7 +176,7 @@ ob_start();
Create User Create User
</button> </button>
<a href="/users" <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"> 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> <i class="fas fa-times mr-2"></i>
Cancel Cancel
</a> </a>
@@ -186,7 +186,7 @@ ob_start();
</div> </div>
<!-- Info Section --> <!-- 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 items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center"> <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> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">What happens next?</h3> <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 space-y-1"> <ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
<li class="flex items-center"> <li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i> <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> <span class="ml-2">Admin-created users are automatically verified and can log in immediately</span>
@@ -230,7 +230,6 @@ function togglePassword(fieldId) {
} }
} }
// Password confirmation validation
document.getElementById('password_confirm').addEventListener('input', function() { document.getElementById('password_confirm').addEventListener('input', function() {
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const confirm = this.value; const confirm = this.value;
@@ -246,8 +245,4 @@ document.getElementById('password_confirm').addEventListener('input', function()
} }
}); });
</script> </script>
{% endblock %}
<?php
$content = ob_get_clean();
require __DIR__ . '/../layout/base.php';
?>

View File

@@ -1,30 +1,30 @@
<?php {% extends 'layout/base.twig' %}
$title = 'Edit User';
$pageTitle = 'Edit User';
$pageDescription = 'Update user information and permissions';
$pageIcon = 'fas fa-user-edit';
ob_start();
?>
{% 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="max-w-3xl 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">
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 flex items-center"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-user-edit text-gray-400 mr-2 text-sm"></i> <i class="fas fa-user-edit text-gray-400 dark:text-slate-500 mr-2 text-sm"></i>
User Information User Information
</h2> </h2>
</div> </div>
<div class="p-6"> <div class="p-6">
<form method="POST" action="/users/<?= $user['id'] ?>/update" class="space-y-5"> <form method="POST" action="/users/{{ user.id }}/update" class="space-y-5">
<?= csrf_field() ?> {{ csrf_field() }}
<input type="hidden" name="id" value="<?= $user['id'] ?>"> <input type="hidden" name="id" value="{{ user.id }}">
<!-- Name & Username Row --> <!-- Name & Username Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- Full Name --> <!-- Full Name -->
<div> <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> Full Name <span class="text-red-500">*</span>
</label> </label>
<input type="text" <input type="text"
@@ -32,30 +32,30 @@ ob_start();
name="full_name" name="full_name"
required required
autofocus autofocus
value="<?= htmlspecialchars($user['full_name'] ?? '') ?>" value="{{ user.full_name|default('') }}"
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"> 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 The user's display name
</p> </p>
</div> </div>
<!-- Username (Read-only) --> <!-- Username (Read-only) -->
<div> <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 Username
</label> </label>
<div class="relative"> <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> <i class="fas fa-at text-sm"></i>
</span> </span>
<input type="text" <input type="text"
id="username" id="username"
value="<?= htmlspecialchars($user['username']) ?>" value="{{ user.username }}"
readonly 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> </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 Username cannot be changed
</p> </p>
</div> </div>
@@ -65,76 +65,76 @@ ob_start();
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- Email --> <!-- Email -->
<div> <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> Email Address <span class="text-red-500">*</span>
</label> </label>
<div class="relative"> <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> <i class="fas fa-envelope text-sm"></i>
</span> </span>
<input type="email" <input type="email"
id="email" id="email"
name="email" name="email"
required required
value="<?= htmlspecialchars($user['email']) ?>" value="{{ 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" 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"> placeholder="john@example.com">
</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">
Used for login and notifications Used for login and notifications
</p> </p>
</div> </div>
<!-- Role --> <!-- Role -->
<div> <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> Role <span class="text-red-500">*</span>
</label> </label>
<div class="relative"> <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> <i class="fas fa-shield-alt text-sm"></i>
</span> </span>
<select id="role" <select id="role"
name="role" name="role"
required 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['role'] === 'user' ? 'selected' : '' ?>>User</option> <option value="user" {{ user.role == 'user' ? 'selected' : '' }}>User</option>
<option value="admin" <?= $user['role'] === 'admin' ? 'selected' : '' ?>>Admin</option> <option value="admin" {{ user.role == 'admin' ? 'selected' : '' }}>Admin</option>
</select> </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> <i class="fas fa-chevron-down text-xs"></i>
</span> </span>
</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">
Admins have full system access Admins have full system access
</p> </p>
</div> </div>
</div> </div>
<!-- Status --> <!-- 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" <input type="checkbox" id="is_active" name="is_active" value="1"
<?= $user['is_active'] ? 'checked' : '' ?> {{ user.is_active ? 'checked' : '' }}
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-slate-600 rounded focus:ring-primary">
<div> <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 Active Account
</label> </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>
</div> </div>
<!-- Password Section --> <!-- Password Section -->
<div class="border-t border-gray-200 pt-5 mt-5"> <div class="border-t border-gray-200 dark:border-slate-700 pt-5 mt-5">
<h3 class="text-sm font-semibold text-gray-900 mb-4 flex items-center"> <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 mr-2"></i> <i class="fas fa-lock text-gray-400 dark:text-slate-500 mr-2"></i>
Change Password Change Password
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5"> <div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- New Password --> <!-- New Password -->
<div> <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 New Password
</label> </label>
<div class="relative"> <div class="relative">
@@ -142,22 +142,22 @@ ob_start();
id="password" id="password"
name="password" name="password"
minlength="8" 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="••••••••"> placeholder="••••••••">
<button type="button" <button type="button"
onclick="togglePassword('password')" 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> <i class="fas fa-eye text-sm" id="password-toggle-icon"></i>
</button> </button>
</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">
Leave blank to keep current password Leave blank to keep current password
</p> </p>
</div> </div>
<!-- Confirm Password --> <!-- Confirm Password -->
<div> <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 Confirm Password
</label> </label>
<div class="relative"> <div class="relative">
@@ -165,15 +165,15 @@ ob_start();
id="password_confirm" id="password_confirm"
name="password_confirm" name="password_confirm"
minlength="8" 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="••••••••"> placeholder="••••••••">
<button type="button" <button type="button"
onclick="togglePassword('password_confirm')" 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> <i class="fas fa-eye text-sm" id="password_confirm-toggle-icon"></i>
</button> </button>
</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">
Re-enter the new password to confirm Re-enter the new password to confirm
</p> </p>
</div> </div>
@@ -188,7 +188,7 @@ ob_start();
Update User Update User
</button> </button>
<a href="/users" <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"> 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> <i class="fas fa-times mr-2"></i>
Cancel Cancel
</a> </a>
@@ -198,7 +198,7 @@ ob_start();
</div> </div>
<!-- Account Info Section --> <!-- 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 items-start">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center"> <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> </div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">Account Details</h3> <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">Account Details</h3>
<ul class="text-xs text-gray-600 space-y-1"> <ul class="text-xs text-gray-600 dark:text-slate-400 space-y-1">
<li class="flex items-center"> <li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i> <i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
<span class="ml-2">Email Verified: <span class="ml-2">Email Verified:
<span class="font-semibold <?= $user['email_verified'] ? 'text-green-600' : 'text-red-600' ?>"> <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' ?> {{ user.email_verified ? 'Yes' : 'No' }}
</span> </span>
</span> </span>
</li> </li>
<li class="flex items-center"> <li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i> <i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
<span class="ml-2">Member Since: <span class="ml-2">Member Since:
<span class="font-semibold text-gray-900"><?= date('M d, Y', strtotime($user['created_at'])) ?></span> <span class="font-semibold text-gray-900 dark:text-white">{{ user.created_at|date('M d, Y') }}</span>
</span> </span>
</li> </li>
<li class="flex items-center"> <li class="flex items-center">
<i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i> <i class="fas fa-circle text-blue-500" style="font-size: 6px;"></i>
<span class="ml-2">Last Login: <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="font-semibold text-gray-900 dark:text-white">{{ user.last_login ? user.last_login|date('M d, Y H:i') : 'Never' }}</span>
</span> </span>
</li> </li>
</ul> </ul>
@@ -250,7 +250,6 @@ function togglePassword(fieldId) {
} }
} }
// Password confirmation validation
document.getElementById('password_confirm').addEventListener('input', function() { document.getElementById('password_confirm').addEventListener('input', function() {
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const confirm = this.value; const confirm = this.value;
@@ -266,8 +265,4 @@ document.getElementById('password_confirm').addEventListener('input', function()
} }
}); });
</script> </script>
{% endblock %}
<?php
$content = ob_get_clean();
require __DIR__ . '/../layout/base.php';
?>

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 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); extract($data);
$viewPath = __DIR__ . "/../app/Views/$view.php"; $viewPath = __DIR__ . "/../app/Views/$view.php";
@@ -13,7 +22,7 @@ abstract class Controller
throw new \Exception("View not found: $view"); throw new \Exception("View not found: $view");
} }
require_once $viewPath; require $viewPath;
} }
protected function json($data, int $status = 200): void protected function json($data, int $status = 200): void

View File

@@ -72,7 +72,12 @@ class Router
// Silently fail if logging is not available // Silently fail if logging is not available
} }
$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'; require_once __DIR__ . '/../app/Views/errors/404.php';
}
return; 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 ?? [];
}));
}
}