Add tags support for domains with filtering and bulk actions

Introduces a 'tags' field to the domains table and UI, allowing users to organize domains with custom tags. Adds tag input and display to create, edit, bulk-add, and view pages, as well as tag-based filtering and bulk tag management (add/remove) in the domain list. Updates backend validation, controller logic, and migrations to support tags, including a new migration and index for efficient tag searches.
This commit is contained in:
Hosteroid
2025-10-12 12:46:16 +03:00
parent 823248f025
commit df2942b356
13 changed files with 868 additions and 16 deletions

View File

@@ -26,6 +26,7 @@ class DomainController extends Controller
$search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100); $search = \App\Helpers\InputValidator::sanitizeSearch($_GET['search'] ?? '', 100);
$status = $_GET['status'] ?? ''; $status = $_GET['status'] ?? '';
$groupId = $_GET['group'] ?? ''; $groupId = $_GET['group'] ?? '';
$tag = $_GET['tag'] ?? '';
$sortBy = $_GET['sort'] ?? 'domain_name'; $sortBy = $_GET['sort'] ?? 'domain_name';
$sortOrder = $_GET['order'] ?? 'asc'; $sortOrder = $_GET['order'] ?? 'asc';
$page = max(1, (int)($_GET['page'] ?? 1)); $page = max(1, (int)($_GET['page'] ?? 1));
@@ -40,7 +41,8 @@ class DomainController extends Controller
$filters = [ $filters = [
'search' => $search, 'search' => $search,
'status' => $status, 'status' => $status,
'group' => $groupId 'group' => $groupId,
'tag' => $tag
]; ];
// Get filtered and paginated domains using model // Get filtered and paginated domains using model
@@ -48,16 +50,21 @@ class DomainController extends Controller
$groups = $this->groupModel->all(); $groups = $this->groupModel->all();
// Get all unique tags for filter dropdown
$allTags = $this->domainModel->getAllTags();
// Format domains for display // Format domains for display
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']); $formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']);
$this->view('domains/index', [ $this->view('domains/index', [
'domains' => $formattedDomains, 'domains' => $formattedDomains,
'groups' => $groups, 'groups' => $groups,
'allTags' => $allTags,
'filters' => [ 'filters' => [
'search' => $search, 'search' => $search,
'status' => $status, 'status' => $status,
'group' => $groupId, 'group' => $groupId,
'tag' => $tag,
'sort' => $sortBy, 'sort' => $sortBy,
'order' => $sortOrder 'order' => $sortOrder
], ],
@@ -88,6 +95,7 @@ class DomainController extends Controller
$domainName = trim($_POST['domain_name'] ?? ''); $domainName = trim($_POST['domain_name'] ?? '');
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
$tagsInput = trim($_POST['tags'] ?? '');
// Validate // Validate
if (empty($domainName)) { if (empty($domainName)) {
@@ -103,6 +111,15 @@ class DomainController extends Controller
return; return;
} }
// Validate tags
$tagValidation = \App\Helpers\InputValidator::validateTags($tagsInput);
if (!$tagValidation['valid']) {
$_SESSION['error'] = $tagValidation['error'];
$this->redirect('/domains/create');
return;
}
$tags = $tagValidation['tags'];
// Check if domain already exists // Check if domain already exists
if ($this->domainModel->existsByDomain($domainName)) { if ($this->domainModel->existsByDomain($domainName)) {
$_SESSION['error'] = 'Domain already exists'; $_SESSION['error'] = 'Domain already exists';
@@ -130,6 +147,7 @@ class DomainController extends Controller
$id = $this->domainModel->create([ $id = $this->domainModel->create([
'domain_name' => $domainName, 'domain_name' => $domainName,
'notification_group_id' => $groupId, 'notification_group_id' => $groupId,
'tags' => $tags,
'registrar' => $whoisData['registrar'], 'registrar' => $whoisData['registrar'],
'registrar_url' => $whoisData['registrar_url'] ?? null, 'registrar_url' => $whoisData['registrar_url'] ?? null,
'expiration_date' => $whoisData['expiration_date'], 'expiration_date' => $whoisData['expiration_date'],
@@ -188,6 +206,16 @@ class DomainController extends Controller
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
$isActive = isset($_POST['is_active']) ? 1 : 0; $isActive = isset($_POST['is_active']) ? 1 : 0;
$tagsInput = trim($_POST['tags'] ?? '');
// Validate tags
$tagValidation = \App\Helpers\InputValidator::validateTags($tagsInput);
if (!$tagValidation['valid']) {
$_SESSION['error'] = $tagValidation['error'];
$this->redirect('/domains/' . $id . '/edit');
return;
}
$tags = $tagValidation['tags'];
// Check if monitoring status changed // Check if monitoring status changed
$statusChanged = ($domain['is_active'] != $isActive); $statusChanged = ($domain['is_active'] != $isActive);
@@ -195,6 +223,7 @@ class DomainController extends Controller
$this->domainModel->update($id, [ $this->domainModel->update($id, [
'notification_group_id' => $groupId, 'notification_group_id' => $groupId,
'tags' => $tags,
'is_active' => $isActive 'is_active' => $isActive
]); ]);
@@ -362,6 +391,7 @@ class DomainController extends Controller
// POST - Process bulk add // POST - Process bulk add
$domainsText = trim($_POST['domains'] ?? ''); $domainsText = trim($_POST['domains'] ?? '');
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null; $groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
$tagsInput = trim($_POST['tags'] ?? '');
if (empty($domainsText)) { if (empty($domainsText)) {
$_SESSION['error'] = 'Please enter at least one domain'; $_SESSION['error'] = 'Please enter at least one domain';
@@ -369,6 +399,15 @@ class DomainController extends Controller
return; return;
} }
// Validate tags
$tagValidation = \App\Helpers\InputValidator::validateTags($tagsInput);
if (!$tagValidation['valid']) {
$_SESSION['error'] = $tagValidation['error'];
$this->redirect('/domains/bulk-add');
return;
}
$tags = $tagValidation['tags'];
// Split by new lines and clean // Split by new lines and clean
$domainNames = array_filter(array_map('trim', explode("\n", $domainsText))); $domainNames = array_filter(array_map('trim', explode("\n", $domainsText)));
@@ -402,6 +441,7 @@ class DomainController extends Controller
$this->domainModel->create([ $this->domainModel->create([
'domain_name' => $domainName, 'domain_name' => $domainName,
'notification_group_id' => $groupId, 'notification_group_id' => $groupId,
'tags' => $tags,
'registrar' => $whoisData['registrar'], 'registrar' => $whoisData['registrar'],
'registrar_url' => $whoisData['registrar_url'] ?? null, 'registrar_url' => $whoisData['registrar_url'] ?? null,
'expiration_date' => $whoisData['expiration_date'], 'expiration_date' => $whoisData['expiration_date'],
@@ -638,5 +678,83 @@ class DomainController extends Controller
$_SESSION['success'] = 'Notes updated successfully'; $_SESSION['success'] = 'Notes updated successfully';
$this->redirect('/domains/' . $id); $this->redirect('/domains/' . $id);
} }
public function bulkAddTags()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/domains');
return;
}
// CSRF Protection
$this->verifyCsrf('/domains');
$domainIds = $_POST['domain_ids'] ?? [];
$tagToAdd = trim($_POST['tag'] ?? '');
if (empty($domainIds) || empty($tagToAdd)) {
$_SESSION['error'] = 'Invalid request';
$this->redirect('/domains');
return;
}
// Validate tag format
if (!preg_match('/^[a-z0-9-]+$/', $tagToAdd)) {
$_SESSION['error'] = 'Invalid tag format (use only letters, numbers, and hyphens)';
$this->redirect('/domains');
return;
}
$updated = 0;
foreach ($domainIds as $id) {
$domain = $this->domainModel->find($id);
if (!$domain) continue;
// Get existing tags
$existingTags = !empty($domain['tags']) ? explode(',', $domain['tags']) : [];
// Add new tag if it doesn't exist
if (!in_array($tagToAdd, $existingTags)) {
$existingTags[] = $tagToAdd;
$newTags = implode(',', $existingTags);
if ($this->domainModel->update($id, ['tags' => $newTags])) {
$updated++;
}
}
}
$_SESSION['success'] = "Tag '$tagToAdd' added to $updated domain(s)";
$this->redirect('/domains');
}
public function bulkRemoveTags()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/domains');
return;
}
// CSRF Protection
$this->verifyCsrf('/domains');
$domainIds = $_POST['domain_ids'] ?? [];
if (empty($domainIds)) {
$_SESSION['error'] = 'No domains selected';
$this->redirect('/domains');
return;
}
$updated = 0;
foreach ($domainIds as $id) {
if ($this->domainModel->update($id, ['tags' => ''])) {
$updated++;
}
}
$_SESSION['success'] = "Tags removed from $updated domain(s)";
$this->redirect('/domains');
}
} }

View File

@@ -47,6 +47,7 @@ class InstallerController extends Controller
'013_create_user_notifications_table.sql', '013_create_user_notifications_table.sql',
'014_add_captcha_settings.sql', '014_add_captcha_settings.sql',
'015_create_error_logs_table.sql', '015_create_error_logs_table.sql',
'016_add_tags_to_domains.sql',
]; ];
try { try {
@@ -264,7 +265,8 @@ class InstallerController extends Controller
'012_link_remember_tokens_to_sessions.sql', '012_link_remember_tokens_to_sessions.sql',
'013_create_user_notifications_table.sql', '013_create_user_notifications_table.sql',
'014_add_captcha_settings.sql', '014_add_captcha_settings.sql',
'015_create_error_logs_table.sql' '015_create_error_logs_table.sql',
'016_add_tags_to_domains.sql',
]; ];
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");

View File

@@ -203,5 +203,52 @@ class InputValidator
} }
return null; return null;
} }
/**
* Validate and sanitize tags
*
* @param string $tagsString Comma-separated tags
* @param int $maxTags Maximum number of tags allowed (default 10)
* @param int $maxLength Maximum length per tag (default 50)
* @return array Array with 'valid' (bool), 'tags' (string), and 'error' (string|null)
*/
public static function validateTags(string $tagsString, int $maxTags = 10, int $maxLength = 50): array
{
if (empty($tagsString)) {
return ['valid' => true, 'tags' => '', 'error' => null];
} }
// Split tags and clean them
$tags = array_filter(array_map('trim', explode(',', $tagsString)));
// Check tag count
if (count($tags) > $maxTags) {
return ['valid' => false, 'tags' => '', 'error' => "Maximum $maxTags tags allowed"];
}
// Validate each tag
$validatedTags = [];
foreach ($tags as $tag) {
$tag = strtolower($tag);
// Check length
if (strlen($tag) > $maxLength) {
return ['valid' => false, 'tags' => '', 'error' => "Tag '$tag' is too long (maximum $maxLength characters)"];
}
// Check format (alphanumeric and hyphens only)
if (!preg_match('/^[a-z0-9-]+$/', $tag)) {
return ['valid' => false, 'tags' => '', 'error' => "Tag '$tag' contains invalid characters (use only letters, numbers, and hyphens)"];
}
// Avoid duplicates
if (!in_array($tag, $validatedTags)) {
$validatedTags[] = $tag;
}
}
return ['valid' => true, 'tags' => implode(',', $validatedTags), 'error' => null];
}
}

View File

@@ -178,6 +178,17 @@ class Domain extends Model
}); });
} }
// Apply tag filter
if (!empty($filters['tag'])) {
$domains = array_filter($domains, function($domain) use ($filters) {
if (empty($domain['tags'])) {
return false;
}
$domainTags = array_map('trim', explode(',', $domain['tags']));
return in_array($filters['tag'], $domainTags);
});
}
// Get total count after filtering // Get total count after filtering
$totalDomains = count($domains); $totalDomains = count($domains);
@@ -210,5 +221,27 @@ class Domain extends Model
] ]
]; ];
} }
/**
* Get all unique tags from all domains
*/
public function getAllTags(): array
{
$stmt = $this->db->query("SELECT DISTINCT tags FROM domains WHERE tags IS NOT NULL AND tags != ''");
$results = $stmt->fetchAll();
$allTags = [];
foreach ($results as $row) {
if (!empty($row['tags'])) {
$tags = array_map('trim', explode(',', $row['tags']));
$allTags = array_merge($allTags, $tags);
}
}
// Return unique, sorted tags
$allTags = array_unique($allTags);
sort($allTags);
return $allTags;
}
} }

View File

@@ -37,6 +37,57 @@ ob_start();
</p> </p>
</div> </div>
<!-- Tags -->
<div>
<label for="tags-input" class="block text-sm font-medium text-gray-700 mb-1.5">
Tags
<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"
id="tags-input"
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Type any tag and press Enter or comma..."
onkeydown="handleTagInput(event)">
<button type="button" onclick="addTagFromInput()" class="absolute right-2 top-1/2 transform -translate-y-1/2 px-3 py-1 bg-primary text-white text-xs rounded hover:bg-primary-dark">
Add
</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.
</p>
<!-- Suggested Tags -->
<div class="mt-2">
<p class="text-xs text-gray-600 mb-1.5">💡 Suggestions:</p>
<div class="flex flex-wrap gap-1.5">
<button type="button" onclick="addTag('production')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-green-50 text-green-700 border-green-200 hover:bg-green-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Production
</button>
<button type="button" onclick="addTag('staging')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Staging
</button>
<button type="button" onclick="addTag('client')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Client
</button>
</div>
</div>
</div>
<!-- Notification Group --> <!-- Notification Group -->
<div> <div>
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
@@ -122,6 +173,96 @@ ob_start();
</div> </div>
</div> </div>
<script>
let tags = [];
const tagColors = {
'production': 'bg-green-100 text-green-700 border-green-300',
'staging': 'bg-yellow-100 text-yellow-700 border-yellow-300',
'development': 'bg-blue-100 text-blue-700 border-blue-300',
'client': 'bg-purple-100 text-purple-700 border-purple-300',
'personal': 'bg-orange-100 text-orange-700 border-orange-300',
'archived': 'bg-gray-100 text-gray-700 border-gray-300'
};
function addTag(tagName) {
tagName = tagName.trim().toLowerCase();
// Validate tag (alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(tagName)) {
return;
}
// Check if tag already exists
if (tags.includes(tagName)) {
return;
}
tags.push(tagName);
updateTagsDisplay();
updateHiddenInput();
// Clear input
document.getElementById('tags-input').value = '';
}
function removeTag(tagName) {
tags = tags.filter(t => t !== tagName);
updateTagsDisplay();
updateHiddenInput();
}
function updateTagsDisplay() {
const display = document.getElementById('tags-display');
display.innerHTML = '';
if (tags.length === 0) {
display.innerHTML = '<span class="text-xs text-gray-400 italic">No tags added yet</span>';
return;
}
tags.forEach(tag => {
const colorClass = tagColors[tag] || 'bg-gray-100 text-gray-700 border-gray-300';
const tagElement = document.createElement('span');
tagElement.className = `inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border ${colorClass}`;
tagElement.innerHTML = `
<i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
${tag}
<button type="button" onclick="removeTag('${tag}')" class="ml-1.5 hover:text-red-600">
<i class="fas fa-times" style="font-size: 9px;"></i>
</button>
`;
display.appendChild(tagElement);
});
}
function updateHiddenInput() {
document.getElementById('tags').value = tags.join(',');
}
function handleTagInput(event) {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
addTagFromInput();
}
}
function addTagFromInput() {
const input = document.getElementById('tags-input');
const value = input.value.trim();
if (value) {
// Handle multiple tags separated by commas
const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t);
newTags.forEach(tag => addTag(tag));
input.value = '';
}
}
// Initialize display
updateTagsDisplay();
</script>
<?php <?php
$content = ob_get_clean(); $content = ob_get_clean();
include __DIR__ . '/../layout/base.php'; include __DIR__ . '/../layout/base.php';

View File

@@ -36,6 +36,65 @@ ob_start();
</p> </p>
</div> </div>
<!-- Tags -->
<div>
<label for="tags-input" class="block text-sm font-medium text-gray-700 mb-1.5">
Tags
<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"
id="tags-input"
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Type any tag and press Enter or comma..."
onkeydown="handleTagInput(event)">
<button type="button" onclick="addTagFromInput()" class="absolute right-2 top-1/2 transform -translate-y-1/2 px-3 py-1 bg-primary text-white text-xs rounded hover:bg-primary-dark">
Add
</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>
Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">,</kbd> to add.
</p>
<!-- Suggested Tags -->
<div class="mt-2">
<p class="text-xs text-gray-600 mb-1.5">💡 Suggestions:</p>
<div class="flex flex-wrap gap-1.5">
<button type="button" onclick="addTag('production')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-green-50 text-green-700 border-green-200 hover:bg-green-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Production
</button>
<button type="button" onclick="addTag('staging')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Staging
</button>
<button type="button" onclick="addTag('development')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Development
</button>
<button type="button" onclick="addTag('client')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Client
</button>
<button type="button" onclick="addTag('personal')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Personal
</button>
</div>
</div>
</div>
<!-- Notification Group --> <!-- Notification Group -->
<div> <div>
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
@@ -125,6 +184,96 @@ ob_start();
</div> </div>
</div> </div>
<script>
let tags = [];
const tagColors = {
'production': 'bg-green-100 text-green-700 border-green-300',
'staging': 'bg-yellow-100 text-yellow-700 border-yellow-300',
'development': 'bg-blue-100 text-blue-700 border-blue-300',
'client': 'bg-purple-100 text-purple-700 border-purple-300',
'personal': 'bg-orange-100 text-orange-700 border-orange-300',
'archived': 'bg-gray-100 text-gray-700 border-gray-300'
};
function addTag(tagName) {
tagName = tagName.trim().toLowerCase();
// Validate tag (alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(tagName)) {
return;
}
// Check if tag already exists
if (tags.includes(tagName)) {
return;
}
tags.push(tagName);
updateTagsDisplay();
updateHiddenInput();
// Clear input
document.getElementById('tags-input').value = '';
}
function removeTag(tagName) {
tags = tags.filter(t => t !== tagName);
updateTagsDisplay();
updateHiddenInput();
}
function updateTagsDisplay() {
const display = document.getElementById('tags-display');
display.innerHTML = '';
if (tags.length === 0) {
display.innerHTML = '<span class="text-xs text-gray-400 italic">No tags added yet</span>';
return;
}
tags.forEach(tag => {
const colorClass = tagColors[tag] || 'bg-gray-100 text-gray-700 border-gray-300';
const tagElement = document.createElement('span');
tagElement.className = `inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border ${colorClass}`;
tagElement.innerHTML = `
<i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
${tag}
<button type="button" onclick="removeTag('${tag}')" class="ml-1.5 hover:text-red-600">
<i class="fas fa-times" style="font-size: 9px;"></i>
</button>
`;
display.appendChild(tagElement);
});
}
function updateHiddenInput() {
document.getElementById('tags').value = tags.join(',');
}
function handleTagInput(event) {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
addTagFromInput();
}
}
function addTagFromInput() {
const input = document.getElementById('tags-input');
const value = input.value.trim();
if (value) {
// Handle multiple tags separated by commas
const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t);
newTags.forEach(tag => addTag(tag));
input.value = '';
}
}
// Initialize display
updateTagsDisplay();
</script>
<?php <?php
$content = ob_get_clean(); $content = ob_get_clean();
include __DIR__ . '/../layout/base.php'; include __DIR__ . '/../layout/base.php';

View File

@@ -39,6 +39,65 @@ ob_start();
</p> </p>
</div> </div>
<!-- Tags -->
<div>
<label for="tags-input" class="block text-sm font-medium text-gray-700 mb-1.5">
Tags
<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"
id="tags-input"
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Type any tag and press Enter or comma..."
onkeydown="handleTagInput(event)">
<button type="button" onclick="addTagFromInput()" class="absolute right-2 top-1/2 transform -translate-y-1/2 px-3 py-1 bg-primary text-white text-xs rounded hover:bg-primary-dark">
Add
</button>
</div>
<!-- Hidden input to store tags for form submission -->
<input type="hidden" id="tags" name="tags" value="<?= htmlspecialchars($domain['tags'] ?? '') ?>">
<p class="mt-1.5 text-xs text-gray-500">
<i class="fas fa-info-circle mr-1"></i>
Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">,</kbd> to add.
</p>
<!-- Suggested Tags -->
<div class="mt-2">
<p class="text-xs text-gray-600 mb-1.5">💡 Suggestions:</p>
<div class="flex flex-wrap gap-1.5">
<button type="button" onclick="addTag('production')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-green-50 text-green-700 border-green-200 hover:bg-green-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Production
</button>
<button type="button" onclick="addTag('staging')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Staging
</button>
<button type="button" onclick="addTag('development')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Development
</button>
<button type="button" onclick="addTag('client')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Client
</button>
<button type="button" onclick="addTag('personal')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Personal
</button>
</div>
</div>
</div>
<!-- Notification Group --> <!-- Notification Group -->
<div> <div>
<label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5"> <label for="notification_group_id" class="block text-sm font-medium text-gray-700 mb-1.5">
@@ -117,6 +176,98 @@ ob_start();
</div> </div>
</div> </div>
<script>
// Initialize tags from existing domain data
const existingTags = '<?= htmlspecialchars($domain['tags'] ?? '') ?>';
let tags = existingTags ? existingTags.split(',').map(t => t.trim().toLowerCase()).filter(t => t) : [];
const tagColors = {
'production': 'bg-green-100 text-green-700 border-green-300',
'staging': 'bg-yellow-100 text-yellow-700 border-yellow-300',
'development': 'bg-blue-100 text-blue-700 border-blue-300',
'client': 'bg-purple-100 text-purple-700 border-purple-300',
'personal': 'bg-orange-100 text-orange-700 border-orange-300',
'archived': 'bg-gray-100 text-gray-700 border-gray-300'
};
function addTag(tagName) {
tagName = tagName.trim().toLowerCase();
// Validate tag (alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(tagName)) {
return;
}
// Check if tag already exists
if (tags.includes(tagName)) {
return;
}
tags.push(tagName);
updateTagsDisplay();
updateHiddenInput();
// Clear input
document.getElementById('tags-input').value = '';
}
function removeTag(tagName) {
tags = tags.filter(t => t !== tagName);
updateTagsDisplay();
updateHiddenInput();
}
function updateTagsDisplay() {
const display = document.getElementById('tags-display');
display.innerHTML = '';
if (tags.length === 0) {
display.innerHTML = '<span class="text-xs text-gray-400 italic">No tags added yet</span>';
return;
}
tags.forEach(tag => {
const colorClass = tagColors[tag] || 'bg-gray-100 text-gray-700 border-gray-300';
const tagElement = document.createElement('span');
tagElement.className = `inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border ${colorClass}`;
tagElement.innerHTML = `
<i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
${tag}
<button type="button" onclick="removeTag('${tag}')" class="ml-1.5 hover:text-red-600">
<i class="fas fa-times" style="font-size: 9px;"></i>
</button>
`;
display.appendChild(tagElement);
});
}
function updateHiddenInput() {
document.getElementById('tags').value = tags.join(',');
}
function handleTagInput(event) {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
addTagFromInput();
}
}
function addTagFromInput() {
const input = document.getElementById('tags-input');
const value = input.value.trim();
if (value) {
// Handle multiple tags separated by commas
const newTags = value.split(',').map(t => t.trim().toLowerCase()).filter(t => t);
newTags.forEach(tag => addTag(tag));
input.value = '';
}
}
// Initialize display
updateTagsDisplay();
</script>
<?php <?php
$content = ob_get_clean(); $content = ob_get_clean();
include __DIR__ . '/../layout/base.php'; include __DIR__ . '/../layout/base.php';

View File

@@ -54,7 +54,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<!-- Filters & Search --> <!-- Filters & Search -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4"> <div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<form method="GET" action="/domains" id="filter-form"> <form method="GET" action="/domains" id="filter-form">
<div class="grid grid-cols-1 md:grid-cols-4 gap-3"> <div class="grid grid-cols-1 md:grid-cols-5 gap-3">
<div> <div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search</label> <label class="block text-xs font-medium text-gray-700 mb-1.5">Search</label>
<div class="relative"> <div class="relative">
@@ -71,6 +71,29 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<option value="inactive" <?= $currentFilters['status'] === 'inactive' ? 'selected' : '' ?>>Inactive</option> <option value="inactive" <?= $currentFilters['status'] === 'inactive' ? 'selected' : '' ?>>Inactive</option>
</select> </select>
</div> </div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Tags</label>
<select name="tag" id="tagFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">All Tags</option>
<?php
$tagIcons = [
'production' => '🟢',
'staging' => '🟡',
'development' => '🔵',
'client' => '🟣',
'personal' => '🟠',
'archived' => '⚪'
];
foreach ($allTags as $tagOption):
$icon = $tagIcons[$tagOption] ?? '🏷️';
$selected = ($currentFilters['tag'] ?? '') === $tagOption ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($tagOption) ?>" <?= $selected ?>>
<?= $icon ?> <?= htmlspecialchars(ucfirst($tagOption)) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div> <div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Group</label> <label class="block text-xs font-medium text-gray-700 mb-1.5">Group</label>
<select name="group" id="groupFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm"> <select name="group" id="groupFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
@@ -83,10 +106,10 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<div class="flex items-end space-x-2"> <div class="flex items-end space-x-2">
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium"> <button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-filter mr-2"></i> <i class="fas fa-filter mr-2"></i>
Apply Filters Apply
</button> </button>
<a href="/domains" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium"> <a href="/domains" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
<i class="fas fa-times mr-2"></i> <i class="fas fa-times"></i>
Clear Clear
</a> </a>
</div> </div>
@@ -107,6 +130,52 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
Refresh Selected Refresh Selected
</button> </button>
<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">
<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">
<button type="button" onclick="bulkAddTag('production')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-green-50 text-green-700 border-green-200 hover:bg-green-100">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Production
</button>
<button type="button" onclick="bulkAddTag('staging')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Staging
</button>
<button type="button" onclick="bulkAddTag('development')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Development
</button>
<button type="button" onclick="bulkAddTag('client')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Client
</button>
<button type="button" onclick="bulkAddTag('personal')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Personal
</button>
</div>
<div class="border-t border-gray-200 pt-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>
</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"> <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"> <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> <i class="fas fa-bell mr-2"></i>
@@ -233,18 +302,40 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<td class="px-6 py-4"> <td class="px-6 py-4">
<input type="checkbox" class="domain-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= $domain['id'] ?>" onchange="updateBulkActions()"> <input type="checkbox" class="domain-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= $domain['id'] ?>" onchange="updateBulkActions()">
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4">
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center"> <div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<i class="fas fa-globe text-primary"></i> <i class="fas fa-globe text-primary"></i>
</div> </div>
<div class="ml-4"> <div class="ml-4">
<a href="/domains/<?= $domain['id'] ?>" class="text-sm font-semibold text-gray-900 hover:text-primary"><?= htmlspecialchars($domain['domain_name']) ?></a> <a href="/domains/<?= $domain['id'] ?>" class="text-sm font-semibold text-gray-900 hover:text-primary"><?= htmlspecialchars($domain['domain_name']) ?></a>
<?php if (!empty($domain['nameservers'])): ?> <div class="flex items-center gap-1.5 mt-1">
<div class="text-xs text-gray-500">NS: <?= htmlspecialchars(explode(',', $domain['nameservers'])[0]) ?></div> <?php
// Display tags (temporary hardcoded for UI demo - will be dynamic later)
$tags = !empty($domain['tags']) ? explode(',', $domain['tags']) : [];
$tagColors = [
'production' => 'bg-green-100 text-green-700 border-green-200',
'staging' => 'bg-yellow-100 text-yellow-700 border-yellow-200',
'development' => 'bg-blue-100 text-blue-700 border-blue-200',
'client' => 'bg-purple-100 text-purple-700 border-purple-200',
'personal' => 'bg-orange-100 text-orange-700 border-orange-200',
'archived' => 'bg-gray-100 text-gray-600 border-gray-200'
];
foreach ($tags as $tag):
$tag = trim($tag);
$colorClass = $tagColors[$tag] ?? 'bg-gray-100 text-gray-700 border-gray-200';
?>
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border <?= $colorClass ?>">
<i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
<?= htmlspecialchars(ucfirst($tag)) ?>
</span>
<?php endforeach; ?>
<?php if (!empty($domain['nameservers']) && empty($tags)): ?>
<span class="text-xs text-gray-500">NS: <?= htmlspecialchars(explode(',', $domain['nameservers'])[0]) ?></span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</div>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<?php if (!empty($domain['registrar'])): ?> <?php if (!empty($domain['registrar'])): ?>
@@ -549,11 +640,89 @@ function bulkDelete() {
form.submit(); form.submit();
} }
function toggleAssignTagsDropdown() {
const dropdown = document.getElementById('assign-tags-dropdown');
dropdown.classList.toggle('hidden');
}
function toggleAssignGroupDropdown() { function toggleAssignGroupDropdown() {
const dropdown = document.getElementById('assign-group-dropdown'); const dropdown = document.getElementById('assign-group-dropdown');
dropdown.classList.toggle('hidden'); dropdown.classList.toggle('hidden');
} }
function bulkAddTag(tagName) {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-add-tags';
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '<?= csrf_token() ?>';
form.appendChild(csrfInput);
// Add tag to add
const tagInput = document.createElement('input');
tagInput.type = 'hidden';
tagInput.name = 'tag';
tagInput.value = tagName;
form.appendChild(tagInput);
// Add domain IDs
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function bulkRemoveAllTags() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
if (!confirm(`Remove all tags from ${ids.length} domain(s)?`)) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/domains/bulk-remove-tags';
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '<?= csrf_token() ?>';
form.appendChild(csrfInput);
// Add domain IDs
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'domain_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
// Update bulk assign form with selected IDs // Update bulk assign form with selected IDs
document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) { document.getElementById('bulk-assign-form')?.addEventListener('submit', function(e) {
const ids = getSelectedIds(); const ids = getSelectedIds();
@@ -573,13 +742,19 @@ document.getElementById('bulk-assign-form')?.addEventListener('submit', function
}); });
// Close dropdown when clicking outside // Close dropdowns when clicking outside
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
const dropdown = document.getElementById('assign-group-dropdown'); const groupDropdown = document.getElementById('assign-group-dropdown');
const button = event.target.closest('button[onclick="toggleAssignGroupDropdown()"]'); const tagsDropdown = document.getElementById('assign-tags-dropdown');
const groupButton = event.target.closest('button[onclick="toggleAssignGroupDropdown()"]');
const tagsButton = event.target.closest('button[onclick="toggleAssignTagsDropdown()"]');
if (!button && !dropdown.contains(event.target)) { if (!groupButton && !groupDropdown.contains(event.target)) {
dropdown?.classList.add('hidden'); groupDropdown?.classList.add('hidden');
}
if (!tagsButton && !tagsDropdown.contains(event.target)) {
tagsDropdown?.classList.add('hidden');
} }
}); });

View File

@@ -15,7 +15,7 @@ ob_start();
<!-- Top Action Bar --> <!-- Top Action Bar -->
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center"> <div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
<div class="flex gap-2"> <div class="flex flex-wrap gap-2">
<?php <?php
// Status badge data prepared by DomainHelper in controller // Status badge data prepared by DomainHelper in controller
$statusClass = $domain['statusClass']; $statusClass = $domain['statusClass'];
@@ -36,6 +36,27 @@ ob_start();
<i class="fas fa-<?= $domain['is_active'] ? 'check-circle' : 'pause-circle' ?> mr-1.5"></i> <i class="fas fa-<?= $domain['is_active'] ? 'check-circle' : 'pause-circle' ?> mr-1.5"></i>
<?= $domain['is_active'] ? 'Monitoring Active' : 'Monitoring Paused' ?> <?= $domain['is_active'] ? 'Monitoring Active' : 'Monitoring Paused' ?>
</span> </span>
<!-- Tags Display -->
<?php
$tags = !empty($domain['tags']) ? explode(',', $domain['tags']) : [];
$tagColors = [
'production' => 'bg-green-100 text-green-700 border-green-200',
'staging' => 'bg-yellow-100 text-yellow-700 border-yellow-200',
'development' => 'bg-blue-100 text-blue-700 border-blue-200',
'client' => 'bg-purple-100 text-purple-700 border-purple-200',
'personal' => 'bg-orange-100 text-orange-700 border-orange-200',
'archived' => 'bg-gray-100 text-gray-600 border-gray-200'
];
foreach ($tags as $tag):
$tag = trim($tag);
$colorClass = $tagColors[$tag] ?? 'bg-gray-100 text-gray-700 border-gray-200';
?>
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold border <?= $colorClass ?>">
<i class="fas fa-tag mr-1.5" style="font-size: 10px;"></i>
<?= htmlspecialchars(ucfirst($tag)) ?>
</span>
<?php endforeach; ?>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline"> <form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">

View File

@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS domains (
status ENUM('active', 'expiring_soon', 'expired', 'error', 'available') DEFAULT 'active', status ENUM('active', 'expiring_soon', 'expired', 'error', 'available') DEFAULT 'active',
whois_data JSON, whois_data JSON,
notes TEXT, notes TEXT,
tags TEXT NULL COMMENT 'Comma-separated tags for organization',
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -36,7 +37,8 @@ CREATE TABLE IF NOT EXISTS domains (
INDEX idx_domain_name (domain_name), INDEX idx_domain_name (domain_name),
INDEX idx_expiration_date (expiration_date), INDEX idx_expiration_date (expiration_date),
INDEX idx_status (status), INDEX idx_status (status),
INDEX idx_is_active (is_active) INDEX idx_is_active (is_active),
INDEX idx_tags (tags(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Notification channels table -- Notification channels table

View File

@@ -0,0 +1,10 @@
-- Add tags column to domains table
-- This allows users to organize domains with custom tags
ALTER TABLE domains
ADD COLUMN tags TEXT NULL COMMENT 'Comma-separated tags for organization'
AFTER notes;
-- Add index for tag searches
ALTER TABLE domains
ADD INDEX idx_tags (tags(255));

View File

@@ -27,6 +27,7 @@ If upgrading from v1.0.0, these incremental migrations will be applied:
- `013_create_user_notifications_table.sql` - User notifications table - `013_create_user_notifications_table.sql` - User notifications table
- `014_add_captcha_settings.sql` - CAPTCHA settings (v2, v3, Turnstile) - `014_add_captcha_settings.sql` - CAPTCHA settings (v2, v3, Turnstile)
- `015_create_error_logs_table.sql` - Error logging and debugging system - `015_create_error_logs_table.sql` - Error logging and debugging system
- `016_add_tags_to_domains.sql` - Domain tags for organization
**Upgrade via:** Web updater at `/install/update` **Upgrade via:** Web updater at `/install/update`

View File

@@ -62,6 +62,8 @@ $router->post('/domains/bulk-refresh', [DomainController::class, 'bulkRefresh'])
$router->post('/domains/bulk-delete', [DomainController::class, 'bulkDelete']); $router->post('/domains/bulk-delete', [DomainController::class, 'bulkDelete']);
$router->post('/domains/bulk-assign-group', [DomainController::class, 'bulkAssignGroup']); $router->post('/domains/bulk-assign-group', [DomainController::class, 'bulkAssignGroup']);
$router->post('/domains/bulk-toggle-status', [DomainController::class, 'bulkToggleStatus']); $router->post('/domains/bulk-toggle-status', [DomainController::class, 'bulkToggleStatus']);
$router->post('/domains/bulk-add-tags', [DomainController::class, 'bulkAddTags']);
$router->post('/domains/bulk-remove-tags', [DomainController::class, 'bulkRemoveTags']);
$router->post('/domains/store', [DomainController::class, 'store']); $router->post('/domains/store', [DomainController::class, 'store']);
$router->get('/domains/{id}', [DomainController::class, 'show']); $router->get('/domains/{id}', [DomainController::class, 'show']);
$router->get('/domains/{id}/edit', [DomainController::class, 'edit']); $router->get('/domains/{id}/edit', [DomainController::class, 'edit']);