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:
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
10
database/migrations/016_add_tags_to_domains.sql
Normal file
10
database/migrations/016_add_tags_to_domains.sql
Normal 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));
|
||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
Reference in New Issue
Block a user