Add import/export and update system
Implement CSV/JSON import and export for domains, notification groups and tags (with masking for sensitive channel data), including size/format validation, in-memory CSV building, and logging. Add tag transfer and bulk transfer actions (admin-only). Introduce a new update system: Add UpdateController and UpdateService, migration 025_add_update_system_v1.1.3.sql, and installer changes to include the new migration and version handling; provide endpoints to check, apply, rollback and configure updates. Update helpers and UI bits: add getUpdateBadgeInfo in LayoutHelper, update notification icons/redirects, and add getMaxUploadSize in ViewHelper. Misc: add NotificationGroup::findByName, tweak .gitignore backups path, and update related views and routes.
This commit is contained in:
@@ -6,17 +6,21 @@ $pageIcon = 'fas fa-layer-group';
|
||||
ob_start();
|
||||
?>
|
||||
|
||||
<!-- Main Form -->
|
||||
<!-- Main Container -->
|
||||
<div class="max-w-4xl 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-layer-group text-gray-400 mr-2 text-sm"></i>
|
||||
Bulk Add Domains
|
||||
</h2>
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 bg-gray-50">
|
||||
<button onclick="switchTab('paste')" id="tab-paste" class="px-6 py-3 text-sm font-medium border-b-2 border-primary text-primary bg-white transition-colors">
|
||||
<i class="fas fa-keyboard mr-2"></i>Paste Domains
|
||||
</button>
|
||||
<button onclick="switchTab('import')" id="tab-import" class="px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 transition-colors">
|
||||
<i class="fas fa-file-upload mr-2"></i>Import from File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
|
||||
<!-- Tab 1: Paste Domains (existing) -->
|
||||
<div id="panel-paste" class="p-6">
|
||||
<form method="POST" action="/domains/bulk-add" class="space-y-5">
|
||||
<?= csrf_field() ?>
|
||||
<!-- Domains Textarea -->
|
||||
@@ -44,10 +48,8 @@ ob_start();
|
||||
<span class="text-gray-400 font-normal">(Optional)</span>
|
||||
</label>
|
||||
|
||||
<!-- Tag Display Area -->
|
||||
<div id="tags-display" class="min-h-[40px] p-2 border border-gray-300 rounded-lg mb-2 flex flex-wrap gap-1.5 bg-gray-50"></div>
|
||||
|
||||
<!-- Tag Input -->
|
||||
<div class="relative">
|
||||
<i class="fas fa-tag absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||
<input type="text"
|
||||
@@ -60,17 +62,15 @@ ob_start();
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden input to store tags for form submission -->
|
||||
<input type="hidden" id="tags" name="tags" value="">
|
||||
|
||||
<p class="mt-1.5 text-xs text-gray-500">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
All imported domains will be tagged with these tags. Type any custom tag or use suggestions below.
|
||||
All imported domains will be tagged with these tags.
|
||||
</p>
|
||||
|
||||
<!-- Available Tags -->
|
||||
<div class="mt-2">
|
||||
<p class="text-xs text-gray-600 mb-1.5">💡 Available Tags:</p>
|
||||
<p class="text-xs text-gray-600 mb-1.5">Available Tags:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
|
||||
@@ -116,11 +116,88 @@ ob_start();
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Import from File -->
|
||||
<div id="panel-import" class="hidden p-6">
|
||||
<form method="POST" action="/domains/import" enctype="multipart/form-data" class="space-y-5" id="domainImportForm">
|
||||
<?= csrf_field() ?>
|
||||
|
||||
<!-- Drag & Drop Zone -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Select File *
|
||||
</label>
|
||||
<div id="domainDropzone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer transition-all hover:border-primary hover:bg-gray-50">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="domainFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="domainDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-3"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1.5">or</p>
|
||||
<span class="inline-flex items-center px-4 py-2 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-3 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
</div>
|
||||
<div id="domainDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700" id="domainFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="domainFileSize"></p>
|
||||
<button type="button" id="domainFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expected Format Info -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p class="text-sm font-medium text-gray-900 mb-2"><i class="fas fa-info-circle text-blue-500 mr-1.5"></i>Expected File Format</p>
|
||||
<p class="text-xs text-gray-600 mb-2">CSV columns or JSON fields:</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-blue-200 font-semibold text-blue-800">domain_name *</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">tags</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notes</code>
|
||||
<code class="px-2 py-0.5 bg-white rounded text-xs border border-gray-200 text-gray-600">notification_group</code>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Fallback Notification Group -->
|
||||
<div>
|
||||
<label for="import_notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Default Notification Group
|
||||
<span class="text-gray-400 font-normal">(for domains without a group in the file)</span>
|
||||
</label>
|
||||
<select id="import_notification_group_id"
|
||||
name="notification_group_id"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm">
|
||||
<option value="">-- No Group (No notifications) --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 pt-3">
|
||||
<button type="submit" id="domainImportBtn"
|
||||
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-file-import mr-2"></i>
|
||||
Import Domains
|
||||
</button>
|
||||
<a href="/domains"
|
||||
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div><!-- end card -->
|
||||
|
||||
<!-- Info Cards -->
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- How it works -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -131,14 +208,13 @@ ob_start();
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">How It Works</h3>
|
||||
<p class="text-xs text-gray-600 leading-relaxed">
|
||||
Paste multiple domain names, one per line. 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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Important notes -->
|
||||
<div class="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
@@ -169,6 +245,23 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(tab) {
|
||||
document.getElementById('panel-paste').classList.toggle('hidden', tab !== 'paste');
|
||||
document.getElementById('panel-import').classList.toggle('hidden', tab !== 'import');
|
||||
|
||||
const pasteTab = document.getElementById('tab-paste');
|
||||
const importTab = document.getElementById('tab-import');
|
||||
|
||||
[pasteTab, importTab].forEach(btn => {
|
||||
btn.classList.remove('border-primary', 'text-primary', 'bg-white', 'border-transparent', 'text-gray-500');
|
||||
});
|
||||
const active = tab === 'paste' ? pasteTab : importTab;
|
||||
const inactive = tab === 'paste' ? importTab : pasteTab;
|
||||
active.classList.add('border-primary', 'text-primary', 'bg-white');
|
||||
inactive.classList.add('border-transparent', 'text-gray-500');
|
||||
}
|
||||
|
||||
let tags = [];
|
||||
|
||||
// Available tags with their colors from the database
|
||||
@@ -254,6 +347,84 @@ function addTagFromInput() {
|
||||
|
||||
// Initialize display
|
||||
updateTagsDisplay();
|
||||
|
||||
// --- Domain Import drag-and-drop & loading ---
|
||||
(function() {
|
||||
const dropzone = document.getElementById('domainDropzone');
|
||||
const fileInput = document.getElementById('domainFileInput');
|
||||
const content = document.getElementById('domainDropzoneContent');
|
||||
const fileInfo = document.getElementById('domainDropzoneFile');
|
||||
const fileName = document.getElementById('domainFileName');
|
||||
const fileSize = document.getElementById('domainFileSize');
|
||||
const removeBtn = document.getElementById('domainFileRemove');
|
||||
const form = document.getElementById('domainImportForm');
|
||||
const submitBtn = document.getElementById('domainImportBtn');
|
||||
|
||||
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-2"></i>Importing & Fetching WHOIS...';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
@@ -29,19 +29,25 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex gap-2 justify-end">
|
||||
<?php if (!empty($domains)): ?>
|
||||
<form method="POST" action="/domains/bulk-refresh" id="refresh-all-form">
|
||||
<?= csrf_field() ?>
|
||||
<?php foreach ($domains as $domain): ?>
|
||||
<input type="hidden" name="domain_ids[]" value="<?= $domain['id'] ?>">
|
||||
<?php endforeach; ?>
|
||||
<button type="submit" 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" title="Refresh all domains on this page">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Refresh Page (<?= count($domains) ?>)
|
||||
<!-- Export Dropdown -->
|
||||
<div class="relative" id="domainExportDropdownWrapper">
|
||||
<button onclick="document.getElementById('domainExportMenu').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>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<a href="/domains/bulk-add" 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">
|
||||
<div id="domainExportMenu" 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="/domains/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="/domains/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>
|
||||
<a href="/domains/bulk-add" 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-layer-group mr-2"></i>
|
||||
Bulk Add
|
||||
</a>
|
||||
@@ -124,126 +130,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when domains are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkRefresh()" 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-sync-alt mr-2"></i>
|
||||
Refresh Selected
|
||||
</button>
|
||||
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" onclick="bulkTransfer()" 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-exchange-alt mr-2"></i>
|
||||
Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignTagsDropdown()" class="inline-flex items-center px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors font-medium">
|
||||
<i class="fas fa-tags mr-2"></i>
|
||||
Manage Tags
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-tags-dropdown" class="hidden absolute left-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-xs font-medium text-gray-700">Tag Management</label>
|
||||
<a href="/tags" class="text-xs text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Manage Tags
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Add Tags Section -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Add Tags to Selected Domains</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="bulkAssignExistingTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-2">
|
||||
<button type="button" onclick="openTagSelector()" class="w-full px-3 py-1.5 bg-blue-100 text-blue-700 text-xs rounded hover:bg-blue-200 font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
Add Custom Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Tags Section -->
|
||||
<div class="border-t border-gray-200 pt-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Remove Tags from Selected Domains</label>
|
||||
<div class="space-y-2">
|
||||
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded hover:bg-gray-200 font-medium">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Remove All Tags
|
||||
</button>
|
||||
<button type="button" onclick="openTagRemovalSelector()" class="w-full px-3 py-1.5 bg-red-100 text-red-700 text-xs rounded hover:bg-red-200 font-medium">
|
||||
<i class="fas fa-minus mr-1"></i>
|
||||
Remove Specific Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2">
|
||||
<button type="button" onclick="toggleAssignTagsDropdown()" class="w-full px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" 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-bell mr-2"></i>
|
||||
Assign Group
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
|
||||
<?= csrf_field() ?>
|
||||
<div class="p-3">
|
||||
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">-- No Group --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2 flex gap-2">
|
||||
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
|
||||
Assign
|
||||
</button>
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" 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 mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<!-- 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
|
||||
@@ -271,6 +158,110 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
|
||||
<!-- Domains List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when domains 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="bulkRefresh()" 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-sync-alt mr-1"></i> Refresh Selected
|
||||
</button>
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignTagsDropdown()" class="inline-flex items-center px-4 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-tags mr-1"></i> Manage Tags
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-tags-dropdown" class="hidden absolute left-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-xs font-medium text-gray-700">Tag Management</label>
|
||||
<a href="/tags" class="text-xs text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Manage Tags
|
||||
</a>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Add Tags to Selected Domains</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="bulkAssignExistingTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-2">
|
||||
<button type="button" onclick="openTagSelector()" class="w-full px-3 py-1.5 bg-blue-100 text-blue-700 text-xs rounded hover:bg-blue-200 font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
Add Custom Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Remove Tags from Selected Domains</label>
|
||||
<div class="space-y-2">
|
||||
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded hover:bg-gray-200 font-medium">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Remove All Tags
|
||||
</button>
|
||||
<button type="button" onclick="openTagRemovalSelector()" class="w-full px-3 py-1.5 bg-red-100 text-red-700 text-xs rounded hover:bg-red-200 font-medium">
|
||||
<i class="fas fa-minus mr-1"></i>
|
||||
Remove Specific Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2">
|
||||
<button type="button" onclick="toggleAssignTagsDropdown()" class="w-full px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="inline-flex items-center px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-bell mr-1"></i> Assign Group
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
|
||||
<?= csrf_field() ?>
|
||||
<div class="p-3">
|
||||
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">-- No Group --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2 flex gap-2">
|
||||
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
|
||||
Assign
|
||||
</button>
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<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($domains)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
@@ -575,11 +566,9 @@ function updateBulkActions() {
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = `${count} domain(s) selected`;
|
||||
selectedCount.textContent = count + ' domain(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
@@ -793,7 +782,7 @@ function bulkTransfer() {
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary-dark">
|
||||
Transfer Domains
|
||||
</button>
|
||||
</div>
|
||||
@@ -1071,6 +1060,14 @@ function submitTagRemoval() {
|
||||
|
||||
// Tags are loaded server-side, no need for DOMContentLoaded
|
||||
|
||||
// Close export dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const wrapper = document.getElementById('domainExportDropdownWrapper');
|
||||
if (wrapper && !wrapper.contains(e.target)) {
|
||||
document.getElementById('domainExportMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
@@ -121,25 +121,6 @@ $currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_o
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when errors are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" 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 mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
@@ -167,6 +148,22 @@ $currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_o
|
||||
|
||||
<!-- 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">
|
||||
@@ -543,11 +540,9 @@ function updateBulkActions() {
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = checkboxes.length + ' error(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
|
||||
@@ -7,8 +7,31 @@ ob_start();
|
||||
?>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mb-4 flex justify-end">
|
||||
<a href="/groups/create" 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">
|
||||
<div class="mb-4 flex gap-2 justify-end">
|
||||
<!-- Export Dropdown -->
|
||||
<div class="relative" id="groupExportDropdownWrapper">
|
||||
<button onclick="document.getElementById('groupExportMenu').classList.toggle('hidden')" class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Export
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="groupExportMenu" class="hidden absolute right-0 mt-1 w-44 bg-white rounded-lg shadow-lg border border-gray-200 z-30 overflow-hidden">
|
||||
<a href="/groups/export?format=csv" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
|
||||
Export as CSV
|
||||
</a>
|
||||
<a href="/groups/export?format=json" class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors border-t border-gray-100">
|
||||
<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('groupImportModal').classList.remove('hidden')" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Import
|
||||
</button>
|
||||
<a href="/groups/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-plus mr-2"></i>
|
||||
Create New Group
|
||||
</a>
|
||||
@@ -31,34 +54,29 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when groups are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" onclick="bulkTransfer()" 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-exchange-alt mr-2"></i>
|
||||
Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" 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 mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- Bulk Actions Bar (shown when groups are selected) -->
|
||||
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 border-b border-blue-200 flex items-center justify-between">
|
||||
<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">
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<button type="button" onclick="bulkTransfer()" class="inline-flex items-center px-4 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<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($groups)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden md:block overflow-x-auto">
|
||||
@@ -208,11 +226,9 @@ function updateBulkActions() {
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = checkboxes.length + ' group(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
@@ -283,30 +299,29 @@ function transferGroup(groupId, groupName) {
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-96">
|
||||
<h3 class="text-lg font-semibold mb-4">Transfer Group</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer group "${groupName}" to another user:</p>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Group</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer group "${groupName}" to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
<input type="hidden" name="group_id" value="${groupId}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User:</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
@@ -319,8 +334,8 @@ function transferGroup(groupId, groupName) {
|
||||
|
||||
// Bulk transfer groups
|
||||
function bulkTransfer() {
|
||||
const selectedCheckboxes = document.querySelectorAll('input[name="group_ids[]"]:checked');
|
||||
if (selectedCheckboxes.length === 0) {
|
||||
const groupIds = getSelectedGroupIds();
|
||||
if (groupIds.length === 0) {
|
||||
alert('Please select groups to transfer');
|
||||
return;
|
||||
}
|
||||
@@ -337,32 +352,31 @@ function bulkTransfer() {
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg p-6 w-96">
|
||||
<h3 class="text-lg font-semibold mb-4">Transfer Groups</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer ${selectedCheckboxes.length} selected group(s) to another user:</p>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Groups</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer ${groupIds.length} selected group(s) to another user.</p>
|
||||
|
||||
<form method="POST" action="/groups/bulk-transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
${Array.from(selectedCheckboxes).map(cb =>
|
||||
`<input type="hidden" name="group_ids[]" value="${cb.value}">`
|
||||
${groupIds.map(id =>
|
||||
`<input type="hidden" name="group_ids[]" value="${id}">`
|
||||
).join('')}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User:</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer All
|
||||
</button>
|
||||
</div>
|
||||
@@ -372,6 +386,159 @@ function bulkTransfer() {
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Close export dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const wrapper = document.getElementById('groupExportDropdownWrapper');
|
||||
if (wrapper && !wrapper.contains(e.target)) {
|
||||
document.getElementById('groupExportMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Close import modal on backdrop click
|
||||
document.getElementById('groupImportModal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
this.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Import Modal -->
|
||||
<div id="groupImportModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
<i class="fas fa-upload text-primary mr-2"></i>Import Notification Groups
|
||||
</h3>
|
||||
<button onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/groups/import" enctype="multipart/form-data" id="groupImportForm">
|
||||
<?= 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="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">
|
||||
<input type="file" name="import_file" accept=".csv,.json" required id="groupFileInput"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
|
||||
<div id="groupDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
</div>
|
||||
<div id="groupDropzoneFile" class="hidden">
|
||||
<i class="fas fa-file-alt text-2xl text-primary mb-1.5"></i>
|
||||
<p class="text-sm font-medium text-gray-700" id="groupFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="groupFileSize"></p>
|
||||
<button type="button" id="groupFileRemove" class="mt-1.5 text-xs text-red-500 hover:text-red-700 font-medium">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-700 font-medium mb-1"><i class="fas fa-info-circle text-blue-500 mr-1"></i> Expected Format</p>
|
||||
<p class="text-xs text-gray-600">CSV: <code class="bg-white px-1 rounded">group_name, group_description, channel_type, channel_config, is_active</code></p>
|
||||
<p class="text-xs text-gray-600 mt-0.5">JSON: array of group objects with nested channels array</p>
|
||||
<p class="text-xs text-gray-500 mt-1.5"><i class="fas fa-exclamation-triangle text-amber-500 mr-1"></i>Channels with masked secrets will be imported as <strong>disabled</strong>. Update the credentials and enable them manually.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end gap-2 rounded-b-lg">
|
||||
<button type="button" onclick="document.getElementById('groupImportModal').classList.add('hidden')" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" id="groupImportBtn" class="px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark transition-colors">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import Groups
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- Group Import drag-and-drop & loading ---
|
||||
(function() {
|
||||
const dropzone = document.getElementById('groupDropzone');
|
||||
const fileInput = document.getElementById('groupFileInput');
|
||||
const content = document.getElementById('groupDropzoneContent');
|
||||
const fileInfo = document.getElementById('groupDropzoneFile');
|
||||
const fileName = document.getElementById('groupFileName');
|
||||
const fileSize = document.getElementById('groupFileSize');
|
||||
const removeBtn = document.getElementById('groupFileRemove');
|
||||
const form = document.getElementById('groupImportForm');
|
||||
const submitBtn = document.getElementById('groupImportBtn');
|
||||
|
||||
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
|
||||
|
||||
@@ -12,9 +12,12 @@ 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)
|
||||
|
||||
@@ -50,6 +50,14 @@
|
||||
|
||||
<!-- 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">
|
||||
@@ -115,11 +123,15 @@
|
||||
<?php if (!empty($recentNotifications)): ?>
|
||||
<?php foreach ($recentNotifications as $notif): ?>
|
||||
<?php
|
||||
// Build the click URL: if domain notification, go to domain; otherwise just mark as read
|
||||
// Build the click URL: update_available → settings#updates; domain → domain page; else mark as read only
|
||||
$hasDomain = !empty($notif['domain_id']);
|
||||
$notifUrl = $hasDomain
|
||||
? '/notifications/' . $notif['id'] . '/mark-read?redirect=domain&domain_id=' . $notif['domain_id']
|
||||
: '/notifications/' . $notif['id'] . '/mark-read';
|
||||
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">
|
||||
|
||||
@@ -68,6 +68,7 @@ $offset = $pagination['showing_from'] - 1;
|
||||
<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>
|
||||
@@ -137,7 +138,9 @@ $offset = $pagination['showing_from'] - 1;
|
||||
$hasDomain = !empty($notification['domain_id']);
|
||||
$domainUrl = $hasDomain ? '/domains/' . $notification['domain_id'] : null;
|
||||
$clickUrl = null;
|
||||
if ($hasDomain && !$notification['is_read']) {
|
||||
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;
|
||||
|
||||
@@ -30,6 +30,37 @@ foreach ($notificationPresets as $key => $preset) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Cached update state for Updates tab (tab badge + modal on load)
|
||||
$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);
|
||||
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,
|
||||
];
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
@@ -64,6 +95,15 @@ foreach ($notificationPresets as $key => $preset) {
|
||||
<i class="fas fa-tools mr-2"></i>
|
||||
Maintenance
|
||||
</button>
|
||||
<button onclick="switchTab('updates')" id="tab-updates" 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 whitespace-nowrap relative">
|
||||
<i class="fas fa-cloud-download-alt mr-2"></i>
|
||||
Updates
|
||||
<?php if (!empty($cachedUpdateAvailable)): ?>
|
||||
<span id="update-badge" class="ml-1.5 inline-flex items-center justify-center w-2 h-2 bg-amber-500 rounded-full" title="Update available"></span>
|
||||
<?php else: ?>
|
||||
<span id="update-badge" class="hidden ml-1.5 inline-flex items-center justify-center w-2 h-2 bg-amber-500 rounded-full"></span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -892,6 +932,243 @@ foreach ($notificationPresets as $key => $preset) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content: Updates -->
|
||||
<div id="content-updates" class="tab-content hidden">
|
||||
<!-- Update Status card: current version + Check button; show "Update available" card when cached -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-6">
|
||||
<div class="px-6 py-5 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<i class="fas fa-sync-alt text-primary text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">Update Status</h3>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Current version <code class="text-primary font-medium">v<?= htmlspecialchars($appSettings['app_version']) ?></code></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<?php if ($updateSettings['last_update_check']): ?>
|
||||
<span class="text-xs text-gray-400 hidden sm:inline">Last checked: <?= date('M d, H:i', strtotime($updateSettings['last_update_check'])) ?></span>
|
||||
<?php endif; ?>
|
||||
<button type="button" id="checkUpdatesBtn" onclick="checkForUpdates()"
|
||||
class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-dark transition-colors shadow-sm">
|
||||
<i class="fas fa-sync-alt mr-2" id="checkUpdatesIcon"></i>
|
||||
Check for Updates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<!-- Cached update available: show card that opens modal (so landing from top bar shows update without clicking Check) -->
|
||||
<?php if ($cachedUpdateAvailable && $cachedUpdateData): ?>
|
||||
<div id="cachedUpdateCard" class="mb-6 p-4 rounded-xl border-2 border-amber-200 bg-amber-50/80 hover:bg-amber-50 transition-colors cursor-pointer" onclick="openUpdateModal(window.__cachedUpdateData)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center">
|
||||
<i class="fas fa-cloud-download-alt text-amber-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-amber-900">Update available</p>
|
||||
<p class="text-xs text-amber-700">
|
||||
<?php if (($cachedUpdateData['type'] ?? '') === 'release'): ?>
|
||||
New release: v<?= htmlspecialchars($cachedUpdateData['latest_version'] ?? '') ?> — click to view details and apply
|
||||
<?php else: ?>
|
||||
<?= (int)($cachedUpdateData['commits_behind'] ?? 0) ?> new commit(s) — click to apply hotfix
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-amber-600 text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (!empty($updateSettings['last_update_check'])): ?>
|
||||
<!-- Last check found no update: show "up to date" from cache -->
|
||||
<div id="cachedUpToDate">
|
||||
<div class="flex items-center p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<i class="fas fa-check-circle text-green-500 text-lg mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-green-800">You're up to date!</p>
|
||||
<p class="text-xs text-green-600 mt-0.5">Version v<?= htmlspecialchars($appSettings['app_version']) ?> is the latest<?= ($updateSettings['update_channel'] ?? 'stable') === 'stable' ? ' stable release' : ' version' ?>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<!-- Inline result: loading / up-to-date / error (update-available goes in modal) -->
|
||||
<div id="updateResultContainer" class="hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update preferences (channel + badge) -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Update Preferences</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Choose update channel and display options</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/settings/updates/preferences" class="p-6">
|
||||
<?= csrf_field() ?>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Update Channel</label>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Stable Channel -->
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors
|
||||
<?= ($updateSettings['update_channel'] ?? 'stable') === 'stable' ? 'border-primary bg-blue-50' : 'border-gray-200 hover:border-gray-300' ?>">
|
||||
<input type="radio" name="update_channel" value="stable"
|
||||
<?= ($updateSettings['update_channel'] ?? 'stable') === 'stable' ? 'checked' : '' ?>
|
||||
class="w-4 h-4 text-primary border-gray-300 focus:ring-primary mt-0.5">
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-shield-alt text-green-600 mr-2"></i>
|
||||
Stable
|
||||
</span>
|
||||
<p class="text-xs text-gray-600 mt-1">Only receive tagged release updates (e.g., v1.2.0). Recommended for production environments.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Latest Channel -->
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors
|
||||
<?= ($updateSettings['update_channel'] ?? 'stable') === 'latest' ? 'border-primary bg-blue-50' : 'border-gray-200 hover:border-gray-300' ?>">
|
||||
<input type="radio" name="update_channel" value="latest"
|
||||
<?= ($updateSettings['update_channel'] ?? 'stable') === 'latest' ? 'checked' : '' ?>
|
||||
class="w-4 h-4 text-primary border-gray-300 focus:ring-primary mt-0.5">
|
||||
<div class="ml-3">
|
||||
<span class="text-sm font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-bolt text-amber-500 mr-2"></i>
|
||||
Latest
|
||||
</span>
|
||||
<p class="text-xs text-gray-600 mt-1">Receive both releases and hotfix commits pushed to the main branch. Get fixes faster.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (($updateSettings['update_channel'] ?? 'stable') === 'latest' && empty($updateSettings['installed_commit_sha'])): ?>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p class="text-sm text-amber-800">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Commit tracking is not yet active. It will begin after the first update is applied through this system. Until then, only release updates will be detected.
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Show update badge in top menu -->
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<label class="flex items-start cursor-pointer">
|
||||
<input type="checkbox" name="update_badge_enabled" value="1"
|
||||
<?= ($updateSettings['update_badge_enabled'] ?? '1') !== '0' ? 'checked' : '' ?>
|
||||
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary mt-0.5">
|
||||
<span class="ml-3 text-sm text-gray-700">
|
||||
Show <strong>Update available</strong> badge in the top menu when an update is available (recommended so admins see it without opening the notification panel).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
|
||||
<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-save mr-2"></i>
|
||||
Save update preferences
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Rollback Section -->
|
||||
<?php if (!empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path'])): ?>
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Rollback</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Revert to the previous version if something went wrong</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-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-gray-900">Warning</p>
|
||||
<p class="text-sm text-gray-700 mt-1">
|
||||
Rolling back will restore application files and database to the state before the last update.
|
||||
If the database restore fails automatically, you can import the SQL backup manually from the <code class="text-xs bg-gray-100 px-1 rounded">backups/</code> directory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-sm text-gray-600">
|
||||
Backup available: <code class="text-xs bg-gray-100 px-1.5 py-0.5 rounded"><?= htmlspecialchars(basename($updateSettings['update_backup_path'])) ?></code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/settings/updates/rollback" class="mt-4"
|
||||
onsubmit="return confirm('Are you sure you want to rollback? This will restore files to the previous version.')">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-undo mr-2"></i>
|
||||
Rollback to Previous Version
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Update Available Modal: same content as before (blue/amber card inside), just in a popup -->
|
||||
<div id="updateAvailableModal" class="fixed inset-0 z-50 hidden" aria-modal="true">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" onclick="closeUpdateModal()"></div>
|
||||
<div class="fixed inset-0 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div id="updateAvailableModalContent" class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] flex flex-col pointer-events-auto overflow-hidden">
|
||||
<div class="flex items-start justify-between px-4 py-3 flex-shrink-0 gap-3 bg-blue-50 border border-blue-200 rounded-t-xl">
|
||||
<div class="flex items-start min-w-0">
|
||||
<i id="updateModalIcon" class="fas fa-arrow-circle-up text-blue-500 text-lg mt-0.5 mr-3"></i>
|
||||
<div>
|
||||
<p id="updateModalTitle" class="text-sm font-semibold text-blue-800">New Release Available</p>
|
||||
<p id="updateModalSubline" class="text-xs text-blue-600 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<a id="updateModalReleaseLink" href="#" target="_blank" rel="noopener" class="text-xs text-blue-600 hover:underline whitespace-nowrap"><i class="fab fa-github mr-1"></i>Release notes</a>
|
||||
<button type="button" onclick="closeUpdateModal()" class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 transition-colors" aria-label="Close">
|
||||
<i class="fas fa-times text-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="updateModalBody" class="update-modal-body p-6 overflow-y-auto flex-1 text-sm min-h-0">
|
||||
<!-- Filled by JS: changelog or commit list only -->
|
||||
</div>
|
||||
<div id="updateModalFooter" class="px-4 py-3 flex-shrink-0 border-t rounded-b-xl">
|
||||
<!-- Filled by JS: Apply form (blue for release, amber for hotfix) -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__cachedUpdateData = <?= $cachedUpdateData ? json_encode($cachedUpdateData) : 'null' ?>;
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js"></script>
|
||||
<style>
|
||||
.changelog-markdown { line-height: 1.5; }
|
||||
.changelog-markdown h1, .changelog-markdown h2, .changelog-markdown h3 { font-weight: 600; margin-top: 0.5em; margin-bottom: 0.25em; }
|
||||
.changelog-markdown h1 { font-size: 1em; }
|
||||
.changelog-markdown h2 { font-size: 0.95em; }
|
||||
.changelog-markdown h3 { font-size: 0.9em; }
|
||||
.changelog-markdown p { margin-bottom: 0.5em; }
|
||||
.changelog-markdown ul, .changelog-markdown ol { margin: 0.25em 0 0.5em 1em; padding-left: 1em; }
|
||||
.changelog-markdown li { margin-bottom: 0.15em; }
|
||||
.changelog-markdown code { background: rgba(0,0,0,0.06); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.95em; }
|
||||
.changelog-markdown pre { background: rgba(0,0,0,0.06); padding: 0.5em; border-radius: 4px; overflow-x: auto; margin: 0.5em 0; font-size: 0.9em; }
|
||||
.changelog-markdown pre code { background: none; padding: 0; }
|
||||
.changelog-markdown a { color: #2563eb; text-decoration: underline; }
|
||||
.changelog-markdown a:hover { color: #1d4ed8; }
|
||||
.changelog-markdown hr { border: none; border-top: 1px solid rgba(0,0,0,0.1); margin: 0.5em 0; }
|
||||
.changelog-markdown blockquote { border-left: 3px solid rgba(0,0,0,0.15); margin: 0.5em 0; padding-left: 0.75em; color: inherit; opacity: 0.95; }
|
||||
.update-modal-body .changelog-markdown { max-height: 20rem; }
|
||||
</style>
|
||||
<script>
|
||||
// Auto-update encryption based on port
|
||||
function updateEncryptionByPort() {
|
||||
@@ -957,7 +1234,7 @@ function switchTab(tabName) {
|
||||
// Load tab from URL hash on page load
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
const hash = window.location.hash.substring(1); // Remove the #
|
||||
const validTabs = ['app', 'email', 'monitoring', 'isolation', 'security', 'system', 'maintenance'];
|
||||
const validTabs = ['app', 'email', 'monitoring', 'isolation', 'security', 'system', 'maintenance', 'updates'];
|
||||
|
||||
if (hash && validTabs.includes(hash)) {
|
||||
switchTab(hash);
|
||||
@@ -1021,6 +1298,208 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Update check AJAX functionality
|
||||
function checkForUpdates() {
|
||||
const btn = document.getElementById('checkUpdatesBtn');
|
||||
const icon = document.getElementById('checkUpdatesIcon');
|
||||
const container = document.getElementById('updateResultContainer');
|
||||
const badge = document.getElementById('update-badge');
|
||||
|
||||
// Hide any cached status cards
|
||||
var cachedCard = document.getElementById('cachedUpdateCard');
|
||||
var cachedUpToDate = document.getElementById('cachedUpToDate');
|
||||
if (cachedCard) cachedCard.classList.add('hidden');
|
||||
if (cachedUpToDate) cachedUpToDate.classList.add('hidden');
|
||||
|
||||
// Show loading state
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-75');
|
||||
icon.classList.add('fa-spin');
|
||||
container.classList.remove('hidden');
|
||||
container.innerHTML = '<div class="flex items-center p-4 bg-gray-50 rounded-lg border border-gray-200"><i class="fas fa-spinner fa-spin text-primary mr-3"></i><span class="text-sm text-gray-600">Checking for updates...</span></div>';
|
||||
|
||||
// Get CSRF token
|
||||
const csrfInput = document.querySelector('input[name="csrf_token"]');
|
||||
const csrfToken = csrfInput ? csrfInput.value : '';
|
||||
|
||||
fetch('/api/updates/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'force=1&csrf_token=' + encodeURIComponent(csrfToken)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-75');
|
||||
icon.classList.remove('fa-spin');
|
||||
|
||||
if (data.error) {
|
||||
container.innerHTML = renderUpdateError(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.available) {
|
||||
badge.classList.remove('hidden');
|
||||
container.classList.add('hidden');
|
||||
container.innerHTML = '';
|
||||
openUpdateModal(data);
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
container.innerHTML = renderUpToDate(data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-75');
|
||||
icon.classList.remove('fa-spin');
|
||||
container.innerHTML = renderUpdateError('Network error: ' + error.message);
|
||||
});
|
||||
}
|
||||
window.checkForUpdates = checkForUpdates;
|
||||
|
||||
function openUpdateModal(data) {
|
||||
const modal = document.getElementById('updateAvailableModal');
|
||||
const bodyEl = document.getElementById('updateModalBody');
|
||||
const footerEl = document.getElementById('updateModalFooter');
|
||||
const titleEl = document.getElementById('updateModalTitle');
|
||||
const sublineEl = document.getElementById('updateModalSubline');
|
||||
const releaseLinkEl = document.getElementById('updateModalReleaseLink');
|
||||
const iconEl = document.getElementById('updateModalIcon');
|
||||
if (!modal || !bodyEl) return;
|
||||
var isRelease = (data.type || 'release') === 'release';
|
||||
if (titleEl) titleEl.textContent = isRelease ? 'New Release Available: v' + (data.latest_version || '') : 'Hotfix Available: ' + (data.commits_behind || 0) + ' commit(s) behind';
|
||||
if (sublineEl) {
|
||||
if (isRelease) {
|
||||
var sub = 'Installed: v' + (data.current_version || '');
|
||||
if (data.published_at) { var d = new Date(data.published_at); sub += ' · Released: ' + String(d.getDate()).padStart(2,'0') + '/' + String(d.getMonth()+1).padStart(2,'0') + '/' + d.getFullYear(); }
|
||||
sublineEl.textContent = sub;
|
||||
sublineEl.classList.remove('hidden');
|
||||
} else {
|
||||
sublineEl.textContent = 'New commits have been pushed to the main branch.';
|
||||
sublineEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
if (releaseLinkEl) {
|
||||
if (isRelease && data.release_url) {
|
||||
releaseLinkEl.href = data.release_url;
|
||||
releaseLinkEl.classList.remove('hidden');
|
||||
} else {
|
||||
releaseLinkEl.href = '#';
|
||||
releaseLinkEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
if (iconEl) iconEl.className = isRelease ? 'fas fa-arrow-circle-up text-blue-500 text-lg mt-0.5 mr-3' : 'fas fa-wrench text-amber-500 text-lg mt-0.5 mr-3';
|
||||
bodyEl.className = 'update-modal-body p-6 overflow-y-auto flex-1 text-sm min-h-0 ' + (isRelease ? 'bg-blue-50 border-x border-b border-blue-200' : 'bg-amber-50 border-x border-b border-amber-200');
|
||||
bodyEl.innerHTML = renderUpdateAvailable(data);
|
||||
var csrf = document.querySelector('input[name=csrf_token]') ? document.querySelector('input[name=csrf_token]').value : '';
|
||||
if (footerEl) {
|
||||
footerEl.className = 'px-4 py-3 flex-shrink-0 border-t rounded-b-xl ' + (isRelease ? 'bg-blue-100 border-blue-200' : 'bg-amber-100 border-amber-200');
|
||||
if (isRelease) {
|
||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply update to v' + escapeHtml(data.latest_version || '') + '? A backup will be created before updating.\')">' +
|
||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||
'<input type="hidden" name="update_type" value="release">' +
|
||||
'<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-download mr-2"></i> Update to v' + escapeHtml(data.latest_version || '') + '</button>' +
|
||||
'</form>';
|
||||
} else {
|
||||
footerEl.innerHTML = '<form method="POST" action="/settings/updates/apply" onsubmit="return confirm(\'Apply hotfix? This will update your files to the latest main branch. A backup will be created first.\')">' +
|
||||
(csrf ? '<input type="hidden" name="csrf_token" value="' + escapeHtml(csrf) + '">' : '') +
|
||||
'<input type="hidden" name="update_type" value="hotfix">' +
|
||||
'<button type="submit" class="inline-flex items-center px-4 py-2 bg-amber-600 text-white text-sm rounded-lg hover:bg-amber-700 transition-colors font-medium"><i class="fas fa-download mr-2"></i> Apply Hotfix</button>' +
|
||||
'</form>';
|
||||
}
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
function closeUpdateModal() {
|
||||
const modal = document.getElementById('updateAvailableModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('updateAvailableModal');
|
||||
if (modal && !modal.classList.contains('hidden')) closeUpdateModal();
|
||||
}
|
||||
});
|
||||
window.openUpdateModal = openUpdateModal;
|
||||
window.closeUpdateModal = closeUpdateModal;
|
||||
|
||||
// When landing on Settings#updates from an external link (e.g. top bar badge or notification),
|
||||
// auto-open the modal. Skip if the user just submitted a form on this page (referrer is self).
|
||||
if (window.location.hash === '#updates' && window.__cachedUpdateData) {
|
||||
var ref = document.referrer || '';
|
||||
var onSettingsPage = ref.indexOf('/settings') !== -1;
|
||||
if (!onSettingsPage) {
|
||||
setTimeout(function() { openUpdateModal(window.__cachedUpdateData); }, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReleaseNotesMarkdown(md) {
|
||||
if (!md) return '';
|
||||
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') return escapeHtml(md);
|
||||
const raw = marked.parse(md, { gfm: true, breaks: true });
|
||||
const allowedTags = ['p', 'br', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'hr'];
|
||||
let out = DOMPurify.sanitize(raw, { ALLOWED_TAGS: allowedTags, ALLOWED_ATTR: ['href', 'target', 'rel'] });
|
||||
out = out.replace(/<a href=/gi, '<a target="_blank" rel="noopener noreferrer" href=');
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderUpToDate(data) {
|
||||
return `
|
||||
<div class="flex items-center p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<i class="fas fa-check-circle text-green-500 text-lg mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-green-800">You're up to date!</p>
|
||||
<p class="text-xs text-green-600 mt-0.5">Version ${escapeHtml(data.current_version)} is the latest${data.channel === 'stable' ? ' stable release' : ' version'}.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderUpdateAvailable(data) {
|
||||
var html = '';
|
||||
if (data.type === 'release') {
|
||||
html = data.release_notes
|
||||
? '<p class="text-sm font-semibold text-blue-800 mb-2 -mt-1">Changelog:</p><hr class="border-blue-200 mb-3"><div class="changelog-markdown text-xs text-blue-700 max-h-40 overflow-y-auto">' + renderReleaseNotesMarkdown(data.release_notes) + '</div>'
|
||||
: '<p class="text-gray-500 text-sm">No changelog available.</p>';
|
||||
} else if (data.type === 'hotfix') {
|
||||
if (data.commit_messages && data.commit_messages.length > 0) {
|
||||
html = '<p class="text-xs font-semibold text-amber-800 mb-2">Recent commits:</p><div class="space-y-1 max-h-40 overflow-y-auto">';
|
||||
data.commit_messages.forEach(function(c) {
|
||||
var firstLine = (c.message || '').split('\n')[0];
|
||||
html += '<div class="text-xs text-amber-700 flex items-start"><code class="text-amber-600 bg-amber-100 px-1 rounded mr-2 flex-shrink-0">' + escapeHtml((c.sha || '').substring(0, 7)) + '</code><span class="truncate">' + escapeHtml(firstLine) + '</span></div>';
|
||||
});
|
||||
html += '</div>';
|
||||
} else {
|
||||
html = '';
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderUpdateError(message) {
|
||||
return `
|
||||
<div class="flex items-center p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<i class="fas fa-exclamation-circle text-red-500 text-lg mr-3"></i>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-red-800">Update check failed</p>
|
||||
<p class="text-xs text-red-600 mt-0.5">${escapeHtml(message)}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Ensure escapeHtml is available (may also be defined in base layout)
|
||||
if (typeof window.escapeHtml === 'undefined') {
|
||||
window.escapeHtml = function(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
}
|
||||
|
||||
// CAPTCHA provider selection logic
|
||||
const captchaProvider = document.getElementById('captcha_provider');
|
||||
if (captchaProvider) {
|
||||
|
||||
@@ -29,6 +29,29 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex gap-2 justify-end">
|
||||
<!-- Export Dropdown -->
|
||||
<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">
|
||||
<i class="fas fa-download mr-2"></i>
|
||||
Export
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</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">
|
||||
<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">
|
||||
<i class="fas fa-file-csv text-green-600 mr-2.5"></i>
|
||||
Export as CSV
|
||||
</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">
|
||||
<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('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>
|
||||
Import
|
||||
</button>
|
||||
<button onclick="openCreateModal()" 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 New Tag
|
||||
@@ -105,27 +128,29 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when tags are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkDeleteTags()" 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 mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<!-- 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 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">
|
||||
<?php if (\Core\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">
|
||||
<i class="fas fa-exchange-alt mr-1"></i> Transfer Selected
|
||||
</button>
|
||||
<?php 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">
|
||||
<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($tags)): ?>
|
||||
<!-- Table View (Desktop) -->
|
||||
<div class="hidden lg:block overflow-x-auto">
|
||||
@@ -189,6 +214,12 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
<a href="/tags/<?= $tag['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<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') ?>">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?>
|
||||
<!-- Global tag - only admins can edit/delete -->
|
||||
<span class="text-xs text-gray-500 italic">Global tag</span>
|
||||
@@ -197,7 +228,7 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
class="text-yellow-600 hover:text-yellow-800" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button onclick="deleteTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
|
||||
<button onclick="deleteTag(<?= $tag['id'] ?>, <?= htmlspecialchars(json_encode($tag['name'])) ?>)"
|
||||
class="text-red-600 hover:text-red-800" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -242,6 +273,12 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
class="text-blue-600 hover:text-blue-800" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<?php if (\Core\Auth::isAdmin()): ?>
|
||||
<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') ?>">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?>
|
||||
<!-- Global tag - only admins can edit/delete -->
|
||||
<span class="text-xs text-gray-500 italic">Global tag</span>
|
||||
@@ -250,7 +287,7 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
class="text-yellow-600 hover:text-yellow-800" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button onclick="deleteTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
|
||||
<button onclick="deleteTag(<?= $tag['id'] ?>, <?= htmlspecialchars(json_encode($tag['name'])) ?>)"
|
||||
class="text-red-600 hover:text-red-800" title="Delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -466,6 +503,64 @@ $currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sor
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 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 Tags
|
||||
</h3>
|
||||
<button onclick="document.getElementById('importModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form method="POST" action="/tags/import" enctype="multipart/form-data" id="tagImportForm">
|
||||
<?= 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="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">
|
||||
<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">
|
||||
<div id="tagDropzoneContent">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600 font-medium">Drag & drop your file here</p>
|
||||
<p class="text-xs text-gray-400 my-1">or</p>
|
||||
<span class="inline-flex items-center px-3 py-1.5 bg-primary text-white text-xs rounded-lg font-medium">
|
||||
<i class="fas fa-folder-open mr-1.5"></i>Browse Files
|
||||
</span>
|
||||
<p class="mt-2.5 text-xs text-gray-400">CSV, JSON · Max <?= \App\Helpers\ViewHelper::getMaxUploadSize() ?></p>
|
||||
</div>
|
||||
<div id="tagDropzoneFile" 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="tagFileName"></p>
|
||||
<p class="text-xs text-gray-400" id="tagFileSize"></p>
|
||||
<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
|
||||
</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">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-500 mt-1">Tags that already exist will be skipped. Only your private tags are imported.</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('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">
|
||||
Cancel
|
||||
</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">
|
||||
<i class="fas fa-upload mr-1.5"></i>Import Tags
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Multi-select functionality
|
||||
function toggleSelectAll(checkbox) {
|
||||
@@ -488,11 +583,9 @@ function updateBulkActions() {
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = `${count} tag(s) selected`;
|
||||
selectedCount.textContent = count + ' tag(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
@@ -552,6 +645,109 @@ function bulkDeleteTags() {
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function transferTag(tagId, tagName) {
|
||||
const users = <?= json_encode($users ?? []) ?>;
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
const escapeHtml = (s) => String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${escapeHtml(user.username)} (${escapeHtml(user.full_name || 'No name')})</option>`
|
||||
).join('');
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Tag</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer tag "${escapeHtml(tagName)}" to another user.</p>
|
||||
|
||||
<form method="POST" action="/tags/transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
<input type="hidden" name="tag_id" value="${tagId}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Delegate click for table/card Transfer buttons (avoids onclick quote issues)
|
||||
document.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.tag-transfer-btn');
|
||||
if (btn) {
|
||||
e.preventDefault();
|
||||
transferTag(parseInt(btn.dataset.tagId, 10), btn.dataset.tagName || '');
|
||||
}
|
||||
});
|
||||
|
||||
function bulkTransferTags() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) {
|
||||
alert('Please select tags to transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = <?= json_encode($users ?? []) ?>;
|
||||
if (users.length === 0) {
|
||||
alert('No users available for transfer');
|
||||
return;
|
||||
}
|
||||
|
||||
const userOptions = users.map(user =>
|
||||
`<option value="${user.id}">${user.username} (${user.full_name || 'No name'})</option>`
|
||||
).join('');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Transfer Tags</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Transfer ${ids.length} selected tag(s) to another user.</p>
|
||||
|
||||
<form method="POST" action="/tags/bulk-transfer">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
${ids.map(id => `<input type="hidden" name="tag_ids[]" value="${id}">`).join('')}
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Transfer to User</label>
|
||||
<select name="target_user_id" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">Select User</option>
|
||||
${userOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick="this.closest('.fixed').remove()" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm font-medium">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark text-sm font-medium">
|
||||
Transfer All
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
document.getElementById('createModal').classList.remove('hidden');
|
||||
document.getElementById('create_name').focus();
|
||||
@@ -618,6 +814,98 @@ document.getElementById('editModal').addEventListener('click', function(e) {
|
||||
closeEditModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('importModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
document.getElementById('importModal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Close export dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const wrapper = document.getElementById('exportDropdownWrapper');
|
||||
if (wrapper && !wrapper.contains(e.target)) {
|
||||
document.getElementById('exportDropdownMenu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Import drag-and-drop & loading ---
|
||||
(function() {
|
||||
const dropzone = document.getElementById('tagDropzone');
|
||||
const fileInput = document.getElementById('tagFileInput');
|
||||
const content = document.getElementById('tagDropzoneContent');
|
||||
const fileInfo = document.getElementById('tagDropzoneFile');
|
||||
const fileName = document.getElementById('tagFileName');
|
||||
const fileSize = document.getElementById('tagFileSize');
|
||||
const removeBtn = document.getElementById('tagFileRemove');
|
||||
const form = document.getElementById('tagImportForm');
|
||||
const submitBtn = document.getElementById('tagImportBtn');
|
||||
|
||||
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
|
||||
|
||||
@@ -37,7 +37,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="import_type" value="check_updates">
|
||||
<button type="submit" <?= $tldStats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2 <?= $tldStats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $tldStats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
|
||||
<button type="submit" <?= $tldStats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2 <?= $tldStats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-primary hover:bg-primary-dark' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $tldStats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Check Updates
|
||||
</button>
|
||||
@@ -45,7 +45,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
||||
<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-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
||||
<button type="submit" 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" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
||||
<i class="fas fa-rocket mr-2"></i>
|
||||
Import TLDs
|
||||
</button>
|
||||
@@ -194,32 +194,29 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Admin Only - Hidden by default, shown when TLDs are selected) -->
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<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-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- 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">
|
||||
@@ -550,11 +547,9 @@ function updateSelectedCount() {
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = `${count} TLD(s) selected`;
|
||||
selectedCount.textContent = count + ' TLD(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
|
||||
@@ -355,7 +355,7 @@ ob_start();
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="flex-1 inline-flex items-center justify-center px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg font-medium transition-colors text-sm">
|
||||
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>
|
||||
|
||||
@@ -95,35 +95,6 @@ $pagination = $pagination ?? [
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when users are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkToggleStatus('active')" 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-user-check mr-2"></i>
|
||||
Activate Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="bulkToggleStatus('inactive')" class="inline-flex items-center px-4 py-2 bg-orange-600 text-white text-sm rounded-lg hover:bg-orange-700 transition-colors font-medium">
|
||||
<i class="fas fa-user-slash mr-2"></i>
|
||||
Deactivate Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="bulkDeleteUsers()" 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 mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
@@ -152,6 +123,28 @@ $pagination = $pagination ?? [
|
||||
|
||||
<!-- 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">
|
||||
@@ -426,11 +419,9 @@ function updateBulkActions() {
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = checkboxes.length + ' user(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
|
||||
Reference in New Issue
Block a user