Replace comma-separated tags with relational tag system.

- Add tags and domain_tags tables
- Support tag management
- Support user isolation (global/private tags)
- Add filtering all domain views to operations
- Update all domain views automatically
This commit is contained in:
Hosteroid
2025-10-25 02:04:00 +03:00
parent 75f0ae35fb
commit 06596b8044
16 changed files with 2729 additions and 170 deletions

View File

@@ -77,6 +77,14 @@ class DomainController extends Controller
$allTags = $this->domainModel->getAllTags(); $allTags = $this->domainModel->getAllTags();
} }
// Get available tags for bulk operations
$tagModel = new \App\Models\Tag();
if ($isolationMode === 'isolated') {
$availableTags = $tagModel->getAllWithUsage($userId);
} else {
$availableTags = $tagModel->getAllWithUsage();
}
// Format domains for display // Format domains for display
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']); $formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']);
@@ -91,6 +99,7 @@ class DomainController extends Controller
'domains' => $formattedDomains, 'domains' => $formattedDomains,
'groups' => $groups, 'groups' => $groups,
'allTags' => $allTags, 'allTags' => $allTags,
'availableTags' => $availableTags,
'users' => $users, 'users' => $users,
'filters' => [ 'filters' => [
'search' => $search, 'search' => $search,
@@ -118,8 +127,17 @@ class DomainController extends Controller
$groups = $this->groupModel->getAllWithChannelCount(); $groups = $this->groupModel->getAllWithChannelCount();
} }
// Get available tags for the new tag system
$tagModel = new \App\Models\Tag();
if ($isolationMode === 'isolated') {
$availableTags = $tagModel->getAllWithUsage($userId);
} else {
$availableTags = $tagModel->getAllWithUsage();
}
$this->view('domains/create', [ $this->view('domains/create', [
'groups' => $groups, 'groups' => $groups,
'availableTags' => $availableTags,
'title' => 'Add Domain' 'title' => 'Add Domain'
]); ]);
} }
@@ -237,7 +255,18 @@ class DomainController extends Controller
public function edit($params = []) public function edit($params = [])
{ {
$id = $params['id'] ?? 0; $id = $params['id'] ?? 0;
$domain = $this->checkDomainAccess($id);
// Get current user and isolation mode
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
// Get domain with tags and groups
if ($isolationMode === 'isolated') {
$domain = $this->domainModel->getWithTagsAndGroups($id, $userId);
} else {
$domain = $this->domainModel->getWithTagsAndGroups($id);
}
if (!$domain) { if (!$domain) {
$_SESSION['error'] = 'Domain not found'; $_SESSION['error'] = 'Domain not found';
@@ -246,19 +275,28 @@ class DomainController extends Controller
} }
// Get groups based on isolation mode // Get groups based on isolation mode
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
if ($isolationMode === 'isolated') { if ($isolationMode === 'isolated') {
$groups = $this->groupModel->getAllWithChannelCount($userId); $groups = $this->groupModel->getAllWithChannelCount($userId);
} else { } else {
$groups = $this->groupModel->getAllWithChannelCount(); $groups = $this->groupModel->getAllWithChannelCount();
} }
// Get available tags for the new tag system
$tagModel = new \App\Models\Tag();
if ($isolationMode === 'isolated') {
$availableTags = $tagModel->getAllWithUsage($userId);
} else {
$availableTags = $tagModel->getAllWithUsage();
}
// Get referrer for cancel button
$referrer = $_GET['from'] ?? '/domains/' . $domain['id'];
$this->view('domains/edit', [ $this->view('domains/edit', [
'domain' => $domain, 'domain' => $domain,
'groups' => $groups, 'groups' => $groups,
'availableTags' => $availableTags,
'referrer' => $referrer,
'title' => 'Edit Domain' 'title' => 'Edit Domain'
]); ]);
} }
@@ -319,7 +357,6 @@ 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,
'expiration_date' => $manualExpirationDate 'expiration_date' => $manualExpirationDate
]); ]);
@@ -362,6 +399,16 @@ class DomainController extends Controller
} }
} }
// Handle tags using the new tag system
if (!empty($tags)) {
$tagModel = new \App\Models\Tag();
$tagModel->updateDomainTags($id, $tags, $userId);
} else {
// Remove all tags from domain
$tagModel = new \App\Models\Tag();
$tagModel->removeAllFromDomain($id);
}
$_SESSION['success'] = 'Domain updated successfully'; $_SESSION['success'] = 'Domain updated successfully';
$this->redirect('/domains/' . $id); $this->redirect('/domains/' . $id);
} }
@@ -471,11 +518,11 @@ class DomainController extends Controller
$settingModel = new \App\Models\Setting(); $settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
// Check domain access based on isolation mode // Get domain with tags and groups
if ($isolationMode === 'isolated') { if ($isolationMode === 'isolated') {
$domain = $this->domainModel->getWithChannels($id, $userId); $domain = $this->domainModel->getWithTagsAndGroups($id, $userId);
} else { } else {
$domain = $this->domainModel->getWithChannels($id); $domain = $this->domainModel->getWithTagsAndGroups($id);
} }
if (!$domain) { if (!$domain) {
@@ -503,9 +550,18 @@ class DomainController extends Controller
$formattedDomain['activeChannelCount'] = \App\Helpers\DomainHelper::getActiveChannelCount($domain['channels']); $formattedDomain['activeChannelCount'] = \App\Helpers\DomainHelper::getActiveChannelCount($domain['channels']);
} }
// Get available tags for the new tag system
$tagModel = new \App\Models\Tag();
if ($isolationMode === 'isolated') {
$availableTags = $tagModel->getAllWithUsage($userId);
} else {
$availableTags = $tagModel->getAllWithUsage();
}
$this->view('domains/view', [ $this->view('domains/view', [
'domain' => $formattedDomain, 'domain' => $formattedDomain,
'logs' => $logs, 'logs' => $logs,
'availableTags' => $availableTags,
'title' => $domain['domain_name'] 'title' => $domain['domain_name']
]); ]);
} }
@@ -524,8 +580,17 @@ class DomainController extends Controller
$groups = $this->groupModel->getAllWithChannelCount(); $groups = $this->groupModel->getAllWithChannelCount();
} }
// Get available tags for the new tag system
$tagModel = new \App\Models\Tag();
if ($isolationMode === 'isolated') {
$availableTags = $tagModel->getAllWithUsage($userId);
} else {
$availableTags = $tagModel->getAllWithUsage();
}
$this->view('domains/bulk-add', [ $this->view('domains/bulk-add', [
'groups' => $groups, 'groups' => $groups,
'availableTags' => $availableTags,
'title' => 'Bulk Add Domains' 'title' => 'Bulk Add Domains'
]); ]);
return; return;
@@ -1007,6 +1072,7 @@ class DomainController extends Controller
$settingModel = new \App\Models\Setting(); $settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$tagModel = new \App\Models\Tag();
$updated = 0; $updated = 0;
foreach ($domainIds as $id) { foreach ($domainIds as $id) {
// Check domain access based on isolation mode // Check domain access based on isolation mode
@@ -1016,7 +1082,7 @@ class DomainController extends Controller
$domain = $this->domainModel->find($id); $domain = $this->domainModel->find($id);
} }
if ($domain && $this->domainModel->update($id, ['tags' => ''])) { if ($domain && $tagModel->removeAllFromDomain($id)) {
$updated++; $updated++;
} }
} }
@@ -1025,6 +1091,112 @@ class DomainController extends Controller
$this->redirect('/domains'); $this->redirect('/domains');
} }
/**
* Bulk remove specific tag from domains
*/
public function bulkRemoveSpecificTag()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/domains');
return;
}
// CSRF Protection
$this->verifyCsrf('/domains');
$domainIds = $_POST['domain_ids'] ?? [];
$tagId = (int)($_POST['tag_id'] ?? 0);
if (empty($domainIds) || !$tagId) {
$_SESSION['error'] = 'Invalid request';
$this->redirect('/domains');
return;
}
$tagModel = new \App\Models\Tag();
$tag = $tagModel->find($tagId);
if (!$tag) {
$_SESSION['error'] = 'Tag not found';
$this->redirect('/domains');
return;
}
// Get current user and isolation mode
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$removed = 0;
foreach ($domainIds as $domainId) {
// Check domain access based on isolation mode
if ($isolationMode === 'isolated') {
$domain = $this->domainModel->findWithIsolation($domainId, $userId);
} else {
$domain = $this->domainModel->find($domainId);
}
if ($domain && $tagModel->removeFromDomain($domainId, $tagId)) {
$removed++;
}
}
$_SESSION['success'] = "Tag '{$tag['name']}' removed from $removed domain(s)";
$this->redirect('/domains');
}
/**
* Bulk assign existing tag to domains
*/
public function bulkAssignExistingTag()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/domains');
return;
}
// CSRF Protection
$this->verifyCsrf('/domains');
$domainIds = $_POST['domain_ids'] ?? [];
$tagId = (int)($_POST['tag_id'] ?? 0);
if (empty($domainIds) || !$tagId) {
$_SESSION['error'] = 'Invalid request';
$this->redirect('/domains');
return;
}
$tagModel = new \App\Models\Tag();
$tag = $tagModel->find($tagId);
if (!$tag) {
$_SESSION['error'] = 'Tag not found';
$this->redirect('/domains');
return;
}
// Get current user and isolation mode
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$added = 0;
foreach ($domainIds as $domainId) {
// Check domain access based on isolation mode
if ($isolationMode === 'isolated') {
$domain = $this->domainModel->findWithIsolation($domainId, $userId);
} else {
$domain = $this->domainModel->find($domainId);
}
if ($domain && $tagModel->addToDomain($domainId, $tagId)) {
$added++;
}
}
$_SESSION['success'] = "Tag '{$tag['name']}' added to $added domain(s)";
$this->redirect('/domains');
}
/** /**
* Transfer domain to another user (Admin only) * Transfer domain to another user (Admin only)
*/ */
@@ -1125,5 +1297,34 @@ class DomainController extends Controller
$_SESSION['success'] = "$transferred domain(s) transferred to {$targetUser['username']}"; $_SESSION['success'] = "$transferred domain(s) transferred to {$targetUser['username']}";
$this->redirect('/domains'); $this->redirect('/domains');
} }
/**
* Get tags for specific domains (API endpoint)
*/
public function getTagsForDomains()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->json(['error' => 'Method not allowed'], 405);
return;
}
// Get JSON input
$input = json_decode(file_get_contents('php://input'), true);
if (!isset($input['domain_ids']) || !is_array($input['domain_ids'])) {
$this->json(['error' => 'Invalid domain IDs'], 400);
return;
}
$domainIds = array_map('intval', $input['domain_ids']);
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
// Get tags that are assigned to the specified domains
$tags = $this->domainModel->getTagsForDomains($domainIds, $isolationMode === 'isolated' ? $userId : null);
$this->json(['tags' => $tags]);
}
} }

View File

@@ -50,6 +50,7 @@ class InstallerController extends Controller
'017_add_two_factor_authentication.sql', '017_add_two_factor_authentication.sql',
'018_add_user_isolation.sql', '018_add_user_isolation.sql',
'019_add_webhook_channel_type.sql', '019_add_webhook_channel_type.sql',
'020_create_tags_system.sql',
]; ];
try { try {
@@ -185,7 +186,8 @@ class InstallerController extends Controller
'016_add_tags_to_domains.sql', '016_add_tags_to_domains.sql',
'017_add_two_factor_authentication.sql', '017_add_two_factor_authentication.sql',
'018_add_user_isolation.sql', '018_add_user_isolation.sql',
'019_add_webhook_channel_type.sql' '019_add_webhook_channel_type.sql',
'020_create_tags_system.sql'
]; ];
} }
@@ -367,6 +369,7 @@ class InstallerController extends Controller
'017_add_two_factor_authentication.sql', '017_add_two_factor_authentication.sql',
'018_add_user_isolation.sql', '018_add_user_isolation.sql',
'019_add_webhook_channel_type.sql', '019_add_webhook_channel_type.sql',
'020_create_tags_system.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

@@ -533,7 +533,7 @@ class SettingsController extends Controller
return; return;
} }
$_SESSION['success'] = "Isolation mode enabled. {$migrationResult['domains_assigned']} domains and {$migrationResult['groups_assigned']} groups assigned to admin."; $_SESSION['success'] = "Isolation mode enabled. {$migrationResult['domains_assigned']} domains, {$migrationResult['groups_assigned']} groups, and {$migrationResult['tags_assigned']} tags assigned to admin.";
} else { } else {
// Switching back to shared mode // Switching back to shared mode
$this->settingModel->setValue('user_isolation_mode', 'shared'); $this->settingModel->setValue('user_isolation_mode', 'shared');
@@ -572,6 +572,10 @@ class SettingsController extends Controller
$groupModel = new \App\Models\NotificationGroup(); $groupModel = new \App\Models\NotificationGroup();
$groupCount = $groupModel->assignUnassignedGroupsToUser($adminId); $groupCount = $groupModel->assignUnassignedGroupsToUser($adminId);
// Assign all tags to admin
$tagModel = new \App\Models\Tag();
$tagCount = $tagModel->assignUnassignedTagsToUser($adminId);
// Set isolation mode // Set isolation mode
$this->settingModel->setValue('user_isolation_mode', 'isolated'); $this->settingModel->setValue('user_isolation_mode', 'isolated');
@@ -579,7 +583,8 @@ class SettingsController extends Controller
'success' => true, 'success' => true,
'admin_id' => $adminId, 'admin_id' => $adminId,
'domains_assigned' => $domainCount, 'domains_assigned' => $domainCount,
'groups_assigned' => $groupCount 'groups_assigned' => $groupCount,
'tags_assigned' => $tagCount
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {

View File

@@ -0,0 +1,492 @@
<?php
namespace App\Controllers;
use App\Models\Tag;
use App\Models\Domain;
use Core\Controller;
class TagController extends Controller
{
private $tagModel;
private $domainModel;
public function __construct()
{
$this->tagModel = new Tag();
$this->domainModel = new Domain();
}
/**
* Show tag management page
*/
public function index()
{
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
// Get filter parameters
$search = $_GET['search'] ?? '';
$color = $_GET['color'] ?? '';
$type = $_GET['type'] ?? '';
$sortBy = $_GET['sort'] ?? 'name';
$sortOrder = $_GET['order'] ?? 'asc';
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25))); // Between 10 and 100
// Prepare filters array
$filters = [
'search' => $search,
'color' => $color,
'type' => $type,
'sort' => $sortBy,
'order' => $sortOrder
];
// Get filtered and paginated tags
$result = $this->tagModel->getFilteredPaginated($filters, $sortBy, $sortOrder, $page, $perPage, $isolationMode === 'isolated' ? $userId : null);
$availableColors = $this->tagModel->getAvailableColors();
$this->view('tags/index', [
'tags' => $result['tags'],
'pagination' => $result['pagination'],
'filters' => $filters,
'availableColors' => $availableColors,
'isolationMode' => $isolationMode
]);
}
/**
* Create new tag
*/
public function create()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tags');
return;
}
$this->verifyCsrf('/tags');
$name = trim($_POST['name'] ?? '');
$color = $_POST['color'] ?? 'bg-gray-100 text-gray-700 border-gray-300';
$description = trim($_POST['description'] ?? '');
$userId = \Core\Auth::id();
if (empty($name)) {
$_SESSION['error'] = 'Tag name is required';
$this->redirect('/tags');
return;
}
// Validate tag name format
if (!preg_match('/^[a-z0-9-]+$/', $name)) {
$_SESSION['error'] = 'Invalid tag name format (use only letters, numbers, and hyphens)';
$this->redirect('/tags');
return;
}
// Check isolation mode
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$data = [
'name' => $name,
'color' => $color,
'description' => $description,
'user_id' => $isolationMode === 'isolated' ? $userId : null
];
if ($this->tagModel->create($data)) {
$_SESSION['success'] = "Tag '$name' created successfully";
} else {
$_SESSION['error'] = 'Failed to create tag (name may already exist)';
}
$this->redirect('/tags');
}
/**
* Update tag
*/
public function update()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tags');
return;
}
$this->verifyCsrf('/tags');
$id = (int)($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$color = $_POST['color'] ?? 'bg-gray-100 text-gray-700 border-gray-300';
$description = trim($_POST['description'] ?? '');
$userId = \Core\Auth::id();
if (!$id || empty($name)) {
$_SESSION['error'] = 'Invalid request';
$this->redirect('/tags');
return;
}
// Check if user can access this tag in isolation mode
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
if ($isolationMode === 'isolated' && !$this->tagModel->canUserAccessTag($id, $userId, true)) {
$_SESSION['error'] = 'You do not have permission to edit this tag';
$this->redirect('/tags');
return;
}
// Check if this is a global tag (user_id = NULL) - only admins can edit global tags
$tag = $this->tagModel->find($id);
if ($tag && $tag['user_id'] === null && !\Core\Auth::isAdmin()) {
$_SESSION['error'] = 'Only administrators can edit global tags';
$this->redirect('/tags');
return;
}
// Validate tag name format
if (!preg_match('/^[a-z0-9-]+$/', $name)) {
$_SESSION['error'] = 'Invalid tag name format (use only letters, numbers, and hyphens)';
$this->redirect('/tags');
return;
}
$data = [
'name' => $name,
'color' => $color,
'description' => $description
];
if ($this->tagModel->update($id, $data)) {
$_SESSION['success'] = "Tag updated successfully";
} else {
$_SESSION['error'] = 'Failed to update tag';
}
$this->redirect('/tags');
}
/**
* Delete tag
*/
public function delete()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tags');
return;
}
$this->verifyCsrf('/tags');
$id = (int)($_POST['id'] ?? 0);
$userId = \Core\Auth::id();
if (!$id) {
$_SESSION['error'] = 'Invalid request';
$this->redirect('/tags');
return;
}
// Check if user can access this tag in isolation mode
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
if ($isolationMode === 'isolated' && !$this->tagModel->canUserAccessTag($id, $userId, true)) {
$_SESSION['error'] = 'You do not have permission to delete this tag';
$this->redirect('/tags');
return;
}
// Check if this is a global tag (user_id = NULL) - only admins can delete global tags
$tag = $this->tagModel->find($id);
if ($tag && $tag['user_id'] === null && !\Core\Auth::isAdmin()) {
$_SESSION['error'] = 'Only administrators can delete global tags';
$this->redirect('/tags');
return;
}
$tag = $this->tagModel->find($id);
if (!$tag) {
$_SESSION['error'] = 'Tag not found';
$this->redirect('/tags');
return;
}
if ($this->tagModel->deleteWithRelationships($id)) {
$_SESSION['success'] = "Tag '{$tag['name']}' deleted successfully";
} else {
$_SESSION['error'] = 'Failed to delete tag';
}
$this->redirect('/tags');
}
/**
* Show domains for a specific tag
*/
public function show($params = [])
{
$id = (int)($params['id'] ?? 0);
if (!$id) {
$_SESSION['error'] = 'Invalid tag ID';
$this->redirect('/tags');
return;
}
$tag = $this->tagModel->find($id);
if (!$tag) {
$_SESSION['error'] = 'Tag not found';
$this->redirect('/tags');
return;
}
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
// Get domains for this tag with proper formatting
$domainModel = new \App\Models\Domain();
$rawDomains = $this->tagModel->getDomainsForTag($id, $isolationMode === 'isolated' ? $userId : null);
// Format domains using DomainHelper (same as other pages)
$domains = [];
foreach ($rawDomains as $domain) {
$domains[] = \App\Helpers\DomainHelper::formatForDisplay($domain);
}
// Get current filters from request
$filters = [
'search' => $_GET['search'] ?? '',
'status' => $_GET['status'] ?? '',
'registrar' => $_GET['registrar'] ?? '',
'sort' => $_GET['sort'] ?? 'domain_name',
'order' => $_GET['order'] ?? 'asc'
];
// Apply filters
if (!empty($filters['search'])) {
$domains = array_filter($domains, function($domain) use ($filters) {
return stripos($domain['domain_name'], $filters['search']) !== false;
});
}
if (!empty($filters['status'])) {
$domains = array_filter($domains, function($domain) use ($filters) {
return $domain['status'] === $filters['status'];
});
}
if (!empty($filters['registrar'])) {
$domains = array_filter($domains, function($domain) use ($filters) {
return stripos($domain['registrar'] ?? '', $filters['registrar']) !== false;
});
}
// Apply sorting
usort($domains, function($a, $b) use ($filters) {
$aVal = $a[$filters['sort']] ?? '';
$bVal = $b[$filters['sort']] ?? '';
$comparison = strcasecmp($aVal, $bVal);
return $filters['order'] === 'desc' ? -$comparison : $comparison;
});
// Pagination
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = max(10, min(100, (int)($_GET['per_page'] ?? 25)));
$total = count($domains);
$totalPages = ceil($total / $perPage);
$offset = ($page - 1) * $perPage;
$paginatedDomains = array_slice($domains, $offset, $perPage);
$pagination = [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => $totalPages,
'showing_from' => $total > 0 ? $offset + 1 : 0,
'showing_to' => min($offset + $perPage, $total)
];
$this->view('tags/view', [
'tag' => $tag,
'domains' => $paginatedDomains,
'filters' => $filters,
'pagination' => $pagination
]);
}
/**
* Bulk add tag to domains
*/
public function bulkAddToDomains()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/domains');
return;
}
$this->verifyCsrf('/domains');
$domainIds = $_POST['domain_ids'] ?? [];
$tagId = (int)($_POST['tag_id'] ?? 0);
if (empty($domainIds) || !$tagId) {
$_SESSION['error'] = 'Invalid request';
$this->redirect('/domains');
return;
}
$tag = $this->tagModel->find($tagId);
if (!$tag) {
$_SESSION['error'] = 'Tag not found';
$this->redirect('/domains');
return;
}
// Get current user and isolation mode
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$added = 0;
foreach ($domainIds as $domainId) {
// Check domain access based on isolation mode
if ($isolationMode === 'isolated') {
$domain = $this->domainModel->findWithIsolation($domainId, $userId);
} else {
$domain = $this->domainModel->find($domainId);
}
if ($domain && $this->tagModel->addToDomain($domainId, $tagId)) {
$added++;
}
}
$_SESSION['success'] = "Tag '{$tag['name']}' added to $added domain(s)";
$this->redirect('/domains');
}
/**
* Bulk remove tag from domains
*/
public function bulkRemoveFromDomains()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/domains');
return;
}
$this->verifyCsrf('/domains');
$domainIds = $_POST['domain_ids'] ?? [];
$tagId = (int)($_POST['tag_id'] ?? 0);
if (empty($domainIds) || !$tagId) {
$_SESSION['error'] = 'Invalid request';
$this->redirect('/domains');
return;
}
$tag = $this->tagModel->find($tagId);
if (!$tag) {
$_SESSION['error'] = 'Tag not found';
$this->redirect('/domains');
return;
}
// Get current user and isolation mode
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$removed = 0;
foreach ($domainIds as $domainId) {
// Check domain access based on isolation mode
if ($isolationMode === 'isolated') {
$domain = $this->domainModel->findWithIsolation($domainId, $userId);
} else {
$domain = $this->domainModel->find($domainId);
}
if ($domain && $this->tagModel->removeFromDomain($domainId, $tagId)) {
$removed++;
}
}
$_SESSION['success'] = "Tag '{$tag['name']}' removed from $removed domain(s)";
$this->redirect('/domains');
}
/**
* Bulk delete tags
*/
public function bulkDelete()
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tags');
return;
}
// Verify CSRF token
if (!\Core\Csrf::verify($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = 'Invalid request';
$this->redirect('/tags');
return;
}
$tagIds = $_POST['tag_ids'] ?? [];
if (empty($tagIds)) {
$_SESSION['error'] = 'No tags selected';
$this->redirect('/tags');
return;
}
$userId = \Core\Auth::id();
$settingModel = new \App\Models\Setting();
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
$deleted = 0;
$errors = [];
foreach ($tagIds as $tagId) {
$tagId = (int)$tagId;
// Check if user can access this tag
if (!$this->tagModel->canUserAccessTag($tagId, $userId, $isolationMode === 'isolated')) {
$errors[] = "You don't have permission to delete tag ID $tagId";
continue;
}
// Check if it's a global tag and user is not admin
$tag = $this->tagModel->find($tagId);
if ($tag && $tag['user_id'] === null && !\Core\Auth::isAdmin()) {
$errors[] = "Only administrators can delete global tags";
continue;
}
if ($this->tagModel->delete($tagId)) {
$deleted++;
} else {
$errors[] = "Failed to delete tag ID $tagId";
}
}
if ($deleted > 0) {
$_SESSION['success'] = "$deleted tag(s) deleted successfully";
}
if (!empty($errors)) {
$_SESSION['error'] = implode(', ', $errors);
}
$this->redirect('/tags');
}
}

View File

@@ -21,16 +21,20 @@ class Domain extends Model
*/ */
public function getAllWithGroups(?int $userId = null): array public function getAllWithGroups(?int $userId = null): array
{ {
$sql = "SELECT d.*, ng.name as group_name $sql = "SELECT d.*, ng.name as group_name,
GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ',') as tags,
GROUP_CONCAT(t.color ORDER BY t.name SEPARATOR '|') as tag_colors
FROM domains d FROM domains d
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id"; LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
LEFT JOIN domain_tags dt ON d.id = dt.domain_id
LEFT JOIN tags t ON dt.tag_id = t.id";
if ($userId) { if ($userId) {
$sql .= " WHERE d.user_id = ? ORDER BY d.status DESC, d.expiration_date ASC"; $sql .= " WHERE d.user_id = ? AND (t.user_id = ? OR t.user_id IS NULL) GROUP BY d.id ORDER BY d.status DESC, d.expiration_date ASC";
$stmt = $this->db->prepare($sql); $stmt = $this->db->prepare($sql);
$stmt->execute([$userId]); $stmt->execute([$userId, $userId]);
} else { } else {
$sql .= " ORDER BY d.status DESC, d.expiration_date ASC"; $sql .= " GROUP BY d.id ORDER BY d.status DESC, d.expiration_date ASC";
$stmt = $this->db->query($sql); $stmt = $this->db->query($sql);
} }
@@ -301,12 +305,24 @@ class Domain extends Model
// Apply tag filter // Apply tag filter
if (!empty($filters['tag'])) { if (!empty($filters['tag'])) {
$domains = array_filter($domains, function($domain) use ($filters) { // Get domain IDs that have the specified tag
if (empty($domain['tags'])) { $tagSql = "SELECT DISTINCT dt.domain_id
return false; FROM domain_tags dt
JOIN tags t ON dt.tag_id = t.id
WHERE t.name = ?";
$tagParams = [$filters['tag']];
if ($userId) {
$tagSql .= " AND dt.domain_id IN (SELECT id FROM domains WHERE user_id = ?)";
$tagParams[] = $userId;
} }
$domainTags = array_map('trim', explode(',', $domain['tags']));
return in_array($filters['tag'], $domainTags); $tagStmt = $this->db->prepare($tagSql);
$tagStmt->execute($tagParams);
$taggedDomainIds = array_column($tagStmt->fetchAll(), 'domain_id');
$domains = array_filter($domains, function($domain) use ($taggedDomainIds) {
return in_array($domain['id'], $taggedDomainIds);
}); });
} }
@@ -348,30 +364,54 @@ class Domain extends Model
*/ */
public function getAllTags(?int $userId = null): array public function getAllTags(?int $userId = null): array
{ {
$sql = "SELECT DISTINCT tags FROM domains WHERE tags IS NOT NULL AND tags != ''"; $sql = "SELECT DISTINCT t.name
FROM tags t
JOIN domain_tags dt ON t.id = dt.tag_id
JOIN domains d ON d.id = dt.domain_id";
$params = []; $params = [];
if ($userId) { if ($userId) {
$sql .= " AND user_id = ?"; $sql .= " WHERE d.user_id = ? AND (t.user_id = ? OR t.user_id IS NULL)";
$params[] = $userId;
$params[] = $userId; $params[] = $userId;
} }
$sql .= " ORDER BY t.name";
$stmt = $this->db->prepare($sql); $stmt = $this->db->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
$results = $stmt->fetchAll(); $results = $stmt->fetchAll();
$allTags = []; return array_column($results, 'name');
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); * Get tags that are assigned to specific domains
sort($allTags); */
return $allTags; public function getTagsForDomains(array $domainIds, ?int $userId = null): array
{
if (empty($domainIds)) {
return [];
}
$placeholders = str_repeat('?,', count($domainIds) - 1) . '?';
$sql = "SELECT DISTINCT t.id, t.name, t.color
FROM tags t
JOIN domain_tags dt ON t.id = dt.tag_id
WHERE dt.domain_id IN ($placeholders)";
$params = $domainIds;
if ($userId) {
$sql .= " AND (t.user_id = ? OR t.user_id IS NULL)";
$params[] = $userId;
}
$sql .= " ORDER BY t.name";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
} }
@@ -471,5 +511,47 @@ class Domain extends Model
return $stmt->rowCount(); return $stmt->rowCount();
} }
/**
* Get a single domain with tags and groups
*/
public function getWithTagsAndGroups(int $id, ?int $userId = null): ?array
{
$sql = "SELECT d.*, ng.name as group_name, ng.id as group_id,
GROUP_CONCAT(t.name ORDER BY t.name SEPARATOR ',') as tags,
GROUP_CONCAT(t.color ORDER BY t.name SEPARATOR '|') as tag_colors
FROM domains d
LEFT JOIN notification_groups ng ON d.notification_group_id = ng.id
LEFT JOIN domain_tags dt ON d.id = dt.domain_id
LEFT JOIN tags t ON dt.tag_id = t.id AND (t.user_id = ? OR t.user_id IS NULL)
WHERE d.id = ?";
$params = [$userId, $id];
if ($userId) {
$sql .= " AND d.user_id = ?";
$params[] = $userId;
}
$sql .= " GROUP BY d.id";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$domain = $stmt->fetch();
if (!$domain) {
return null;
}
// Get notification channels for this domain's group
if ($domain['group_id']) {
$channelModel = new NotificationChannel();
$domain['channels'] = $channelModel->getByGroupId($domain['group_id']);
} else {
$domain['channels'] = [];
}
return $domain;
}
} }

424
app/Models/Tag.php Normal file
View File

@@ -0,0 +1,424 @@
<?php
namespace App\Models;
use Core\Model;
class Tag extends Model
{
protected static string $table = 'tags';
protected $fillable = ['name', 'color', 'description'];
/**
* Find tag by ID
*/
public function find(int $id): ?array
{
$sql = "SELECT * FROM tags WHERE id = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$id]);
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Get all tags with usage count
*/
public function getAllWithUsage(?int $userId = null): array
{
$sql = "SELECT t.*,
COALESCE(usage_stats.usage_count, 0) as usage_count
FROM tags t
LEFT JOIN (
SELECT dt.tag_id, COUNT(*) as usage_count
FROM domain_tags dt
JOIN domains d ON d.id = dt.domain_id";
$params = [];
if ($userId) {
$sql .= " WHERE d.user_id = ?";
$params[] = $userId;
}
$sql .= " GROUP BY dt.tag_id
) usage_stats ON t.id = usage_stats.tag_id";
// Add WHERE clause for tag visibility
if ($userId) {
$sql .= " WHERE (t.user_id = ? OR t.user_id IS NULL)";
$params[] = $userId;
}
$sql .= " ORDER BY usage_count DESC, t.name ASC";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Get tags for a specific domain
*/
public function getForDomain(int $domainId): array
{
$sql = "SELECT t.* FROM tags t
JOIN domain_tags dt ON t.id = dt.tag_id
WHERE dt.domain_id = ?
ORDER BY t.name";
$stmt = $this->db->prepare($sql);
$stmt->execute([$domainId]);
return $stmt->fetchAll();
}
/**
* Add tag to domain
*/
public function addToDomain(int $domainId, int $tagId): bool
{
try {
$sql = "INSERT IGNORE INTO domain_tags (domain_id, tag_id) VALUES (?, ?)";
$stmt = $this->db->prepare($sql);
$result = $stmt->execute([$domainId, $tagId]);
if ($result) {
$this->updateUsageCount($tagId);
}
return $result;
} catch (\PDOException $e) {
return false;
}
}
/**
* Remove tag from domain
*/
public function removeFromDomain(int $domainId, int $tagId): bool
{
$sql = "DELETE FROM domain_tags WHERE domain_id = ? AND tag_id = ?";
$stmt = $this->db->prepare($sql);
$result = $stmt->execute([$domainId, $tagId]);
if ($result) {
$this->updateUsageCount($tagId);
}
return $result;
}
/**
* Remove all tags from domain
*/
public function removeAllFromDomain(int $domainId): bool
{
$sql = "DELETE FROM domain_tags WHERE domain_id = ?";
$stmt = $this->db->prepare($sql);
$result = $stmt->execute([$domainId]);
if ($result) {
// Update usage counts for all affected tags
$this->updateAllUsageCounts();
}
return $result;
}
/**
* Update tags for a domain (replace all existing tags)
*/
public function updateDomainTags(int $domainId, string $tagsString, int $userId): bool
{
// Remove all existing tags from domain
$this->removeAllFromDomain($domainId);
if (empty(trim($tagsString))) {
return true; // No tags to add
}
$tags = array_map('trim', explode(',', $tagsString));
$tags = array_filter($tags); // Remove empty tags
if (empty($tags)) {
return true; // No valid tags to add
}
$added = 0;
foreach ($tags as $tagName) {
// Find or create tag
$tag = $this->findByName($tagName, $userId);
if (!$tag) {
// Create new tag
$tagId = $this->create([
'name' => $tagName,
'color' => 'bg-gray-100 text-gray-700 border-gray-300',
'description' => '',
'user_id' => $userId
]);
if ($tagId) {
$this->addToDomain($domainId, $tagId);
$added++;
}
} else {
// Use existing tag
$this->addToDomain($domainId, $tag['id']);
$added++;
}
}
return $added > 0;
}
/**
* Find tag by name for a specific user
*/
public function findByName(string $name, int $userId): ?array
{
$sql = "SELECT * FROM tags WHERE name = ? AND (user_id = ? OR user_id IS NULL) ORDER BY user_id DESC LIMIT 1";
$stmt = $this->db->prepare($sql);
$stmt->execute([$name, $userId]);
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Add tag to multiple domains
*/
public function addToDomains(array $domainIds, int $tagId): int
{
$added = 0;
foreach ($domainIds as $domainId) {
if ($this->addToDomain($domainId, $tagId)) {
$added++;
}
}
return $added;
}
/**
* Remove tag from multiple domains
*/
public function removeFromDomains(array $domainIds, int $tagId): int
{
$removed = 0;
foreach ($domainIds as $domainId) {
if ($this->removeFromDomain($domainId, $tagId)) {
$removed++;
}
}
return $removed;
}
/**
* Update usage count for a specific tag
*/
public function updateUsageCount(int $tagId): void
{
$sql = "UPDATE tags SET usage_count = (
SELECT COUNT(*) FROM domain_tags WHERE tag_id = ?
) WHERE id = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$tagId, $tagId]);
}
/**
* Update usage counts for all tags
*/
public function updateAllUsageCounts(): void
{
$sql = "UPDATE tags SET usage_count = (
SELECT COUNT(*) FROM domain_tags WHERE tag_id = tags.id
)";
$stmt = $this->db->prepare($sql);
$stmt->execute();
}
/**
* Get domains for a specific tag
*/
public function getDomainsForTag(int $tagId, ?int $userId = null): array
{
$sql = "SELECT d.* FROM domains d
JOIN domain_tags dt ON d.id = dt.domain_id
WHERE dt.tag_id = ?";
$params = [$tagId];
if ($userId) {
$sql .= " AND d.user_id = ?";
$params[] = $userId;
}
$sql .= " ORDER BY d.domain_name";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
/**
* Delete tag and all its relationships
*/
public function deleteWithRelationships(int $tagId): bool
{
try {
$this->db->beginTransaction();
// Remove all domain relationships
$sql = "DELETE FROM domain_tags WHERE tag_id = ?";
$stmt = $this->db->prepare($sql);
$stmt->execute([$tagId]);
// Delete the tag
$sql = "DELETE FROM tags WHERE id = ?";
$stmt = $this->db->prepare($sql);
$result = $stmt->execute([$tagId]);
$this->db->commit();
return $result;
} catch (\Exception $e) {
$this->db->rollBack();
return false;
}
}
/**
* Get available colors for tags
*/
public function getAvailableColors(): array
{
return [
'bg-gray-100 text-gray-700 border-gray-300' => 'Gray',
'bg-red-100 text-red-700 border-red-300' => 'Red',
'bg-orange-100 text-orange-700 border-orange-300' => 'Orange',
'bg-yellow-100 text-yellow-700 border-yellow-300' => 'Yellow',
'bg-green-100 text-green-700 border-green-300' => 'Green',
'bg-blue-100 text-blue-700 border-blue-300' => 'Blue',
'bg-indigo-100 text-indigo-700 border-indigo-300' => 'Indigo',
'bg-purple-100 text-purple-700 border-purple-300' => 'Purple',
'bg-pink-100 text-pink-700 border-pink-300' => 'Pink',
'bg-teal-100 text-teal-700 border-teal-300' => 'Teal',
'bg-cyan-100 text-cyan-700 border-cyan-300' => 'Cyan',
'bg-lime-100 text-lime-700 border-lime-300' => 'Lime',
];
}
/**
* Check if user can access a tag
*/
public function canUserAccessTag(int $tagId, int $userId, bool $isolationMode = false): bool
{
if (!$isolationMode) {
return true; // In shared mode, everyone can access all tags
}
$sql = "SELECT id FROM tags WHERE id = ? AND (user_id = ? OR user_id IS NULL)";
$stmt = $this->db->prepare($sql);
$stmt->execute([$tagId, $userId]);
return $stmt->fetch() !== false;
}
/**
* Assign all unassigned tags to a specific user (for isolation mode migration)
*/
public function assignUnassignedTagsToUser(int $userId): int
{
$stmt = $this->db->prepare("UPDATE tags SET user_id = ? WHERE user_id IS NULL");
$stmt->execute([$userId]);
return $stmt->rowCount();
}
/**
* Get tags for user isolation mode
*/
public function getTagsForUser(int $userId, bool $isolationMode = false): array
{
if ($isolationMode) {
$sql = "SELECT * FROM tags WHERE user_id = ? OR user_id IS NULL ORDER BY name";
$stmt = $this->db->prepare($sql);
$stmt->execute([$userId]);
} else {
$sql = "SELECT * FROM tags ORDER BY name";
$stmt = $this->db->prepare($sql);
$stmt->execute();
}
return $stmt->fetchAll();
}
/**
* Get filtered, sorted, and paginated tags
*/
public function getFilteredPaginated(array $filters, string $sortBy, string $sortOrder, int $page, int $perPage, ?int $userId = null): array
{
// Get all tags with usage
$tags = $this->getAllWithUsage($userId);
// Apply search filter
if (!empty($filters['search'])) {
$tags = array_filter($tags, function($tag) use ($filters) {
return stripos($tag['name'], $filters['search']) !== false ||
stripos($tag['description'] ?? '', $filters['search']) !== false;
});
}
// Apply color filter
if (!empty($filters['color'])) {
$tags = array_filter($tags, function($tag) use ($filters) {
return $tag['color'] === $filters['color'];
});
}
// Apply type filter (global vs user)
if (!empty($filters['type'])) {
$tags = array_filter($tags, function($tag) use ($filters) {
if ($filters['type'] === 'global') {
return $tag['user_id'] === null;
} elseif ($filters['type'] === 'user') {
return $tag['user_id'] !== null;
}
return true;
});
}
// Get total count after filtering
$totalTags = count($tags);
// Apply sorting
usort($tags, function($a, $b) use ($sortBy, $sortOrder) {
$aVal = $a[$sortBy] ?? '';
$bVal = $b[$sortBy] ?? '';
// Handle numeric sorting for usage_count
if ($sortBy === 'usage_count') {
$aVal = (int)$aVal;
$bVal = (int)$bVal;
$comparison = $aVal <=> $bVal;
} else {
$comparison = strcasecmp($aVal, $bVal);
}
return $sortOrder === 'desc' ? -$comparison : $comparison;
});
// Calculate pagination
$totalPages = ceil($totalTags / $perPage);
$page = min($page, max(1, $totalPages)); // Ensure page is within valid range
$offset = ($page - 1) * $perPage;
// Slice array for current page
$paginatedTags = array_slice($tags, $offset, $perPage);
return [
'tags' => $paginatedTags,
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $totalTags,
'total_pages' => $totalPages,
'showing_from' => $totalTags > 0 ? $offset + 1 : 0,
'showing_to' => min($offset + $perPage, $totalTags)
]
];
}
}

View File

@@ -68,22 +68,17 @@ ob_start();
All imported domains will be tagged with these tags. Type any custom tag or use suggestions below. All imported domains will be tagged with these tags. Type any custom tag or use suggestions below.
</p> </p>
<!-- Suggested Tags --> <!-- Available Tags -->
<div class="mt-2"> <div class="mt-2">
<p class="text-xs text-gray-600 mb-1.5">💡 Suggestions:</p> <p class="text-xs text-gray-600 mb-1.5">💡 Available Tags:</p>
<div class="flex flex-wrap gap-1.5"> <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"> <?php foreach ($availableTags as $tag): ?>
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Production <?= htmlspecialchars($tag['name']) ?>
</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> </button>
<?php endforeach; ?>
</div> </div>
</div> </div>
</div> </div>
@@ -176,14 +171,12 @@ ob_start();
<script> <script>
let tags = []; let tags = [];
const tagColors = { // Available tags with their colors from the database
'production': 'bg-green-100 text-green-700 border-green-300', const availableTags = <?= json_encode($availableTags) ?>;
'staging': 'bg-yellow-100 text-yellow-700 border-yellow-300', const tagColors = {};
'development': 'bg-blue-100 text-blue-700 border-blue-300', availableTags.forEach(tag => {
'client': 'bg-purple-100 text-purple-700 border-purple-300', tagColors[tag.name] = tag.color;
'personal': 'bg-orange-100 text-orange-700 border-orange-300', });
'archived': 'bg-gray-100 text-gray-700 border-gray-300'
};
function addTag(tagName) { function addTag(tagName) {
tagName = tagName.trim().toLowerCase(); tagName = tagName.trim().toLowerCase();

View File

@@ -67,30 +67,17 @@ ob_start();
Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">,</kbd> to add. Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">,</kbd> to add.
</p> </p>
<!-- Suggested Tags --> <!-- Available Tags -->
<div class="mt-2"> <div class="mt-2">
<p class="text-xs text-gray-600 mb-1.5">💡 Suggestions:</p> <p class="text-xs text-gray-600 mb-1.5">💡 Available Tags:</p>
<div class="flex flex-wrap gap-1.5"> <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"> <?php foreach ($availableTags as $tag): ?>
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Production <?= htmlspecialchars($tag['name']) ?>
</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> </button>
<?php endforeach; ?>
</div> </div>
</div> </div>
</div> </div>
@@ -187,14 +174,12 @@ ob_start();
<script> <script>
let tags = []; let tags = [];
const tagColors = { // Available tags with their colors from the database
'production': 'bg-green-100 text-green-700 border-green-300', const availableTags = <?= json_encode($availableTags) ?>;
'staging': 'bg-yellow-100 text-yellow-700 border-yellow-300', const tagColors = {};
'development': 'bg-blue-100 text-blue-700 border-blue-300', availableTags.forEach(tag => {
'client': 'bg-purple-100 text-purple-700 border-purple-300', tagColors[tag.name] = tag.color;
'personal': 'bg-orange-100 text-orange-700 border-orange-300', });
'archived': 'bg-gray-100 text-gray-700 border-gray-300'
};
function addTag(tagName) { function addTag(tagName) {
tagName = tagName.trim().toLowerCase(); tagName = tagName.trim().toLowerCase();

View File

@@ -70,30 +70,17 @@ ob_start();
Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">,</kbd> to add. Type any custom tag (letters, numbers, hyphens). Press <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">Enter</kbd> or <kbd class="px-1 py-0.5 bg-gray-200 rounded text-xs">,</kbd> to add.
</p> </p>
<!-- Suggested Tags --> <!-- Available Tags -->
<div class="mt-2"> <div class="mt-2">
<p class="text-xs text-gray-600 mb-1.5">💡 Suggestions:</p> <p class="text-xs text-gray-600 mb-1.5">💡 Available Tags:</p>
<div class="flex flex-wrap gap-1.5"> <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"> <?php foreach ($availableTags as $tag): ?>
<button type="button" onclick="addTag('<?= htmlspecialchars($tag['name']) ?>')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80 transition-colors">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Production <?= htmlspecialchars($tag['name']) ?>
</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> </button>
<?php endforeach; ?>
</div> </div>
</div> </div>
</div> </div>
@@ -172,7 +159,7 @@ ob_start();
<i class="fas fa-save mr-2"></i> <i class="fas fa-save mr-2"></i>
Update Domain Update Domain
</button> </button>
<a href="/domains/<?= $domain['id'] ?>" <a href="<?= htmlspecialchars($referrer ?? '/domains/' . $domain['id']) ?>"
class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm"> class="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors text-sm">
<i class="fas fa-times mr-2"></i> <i class="fas fa-times mr-2"></i>
Cancel Cancel
@@ -213,14 +200,12 @@ ob_start();
const existingTags = '<?= htmlspecialchars($domain['tags'] ?? '') ?>'; const existingTags = '<?= htmlspecialchars($domain['tags'] ?? '') ?>';
let tags = existingTags ? existingTags.split(',').map(t => t.trim().toLowerCase()).filter(t => t) : []; let tags = existingTags ? existingTags.split(',').map(t => t.trim().toLowerCase()).filter(t => t) : [];
const tagColors = { // Available tags with their colors from the database
'production': 'bg-green-100 text-green-700 border-green-300', const availableTags = <?= json_encode($availableTags) ?>;
'staging': 'bg-yellow-100 text-yellow-700 border-yellow-300', const tagColors = {};
'development': 'bg-blue-100 text-blue-700 border-blue-300', availableTags.forEach(tag => {
'client': 'bg-purple-100 text-purple-700 border-purple-300', tagColors[tag.name] = tag.color;
'personal': 'bg-orange-100 text-orange-700 border-orange-300', });
'archived': 'bg-gray-100 text-gray-700 border-gray-300'
};
function addTag(tagName) { function addTag(tagName) {
tagName = tagName.trim().toLowerCase(); tagName = tagName.trim().toLowerCase();

View File

@@ -147,34 +147,47 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
</button> </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 id="assign-tags-dropdown" class="hidden absolute left-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
<div class="p-3"> <div class="p-3">
<div class="flex items-center justify-between mb-3">
<label class="block text-xs font-medium text-gray-700">Tag Management</label>
<a href="/tags" class="text-xs text-blue-600 hover:text-blue-800">
<i class="fas fa-cog mr-1"></i>
Manage Tags
</a>
</div>
<!-- Add Tags Section -->
<div class="mb-4">
<label class="block text-xs font-medium text-gray-700 mb-2">Add Tags to Selected Domains</label> <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"> <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"> <?php foreach ($availableTags as $tag): ?>
<button type="button" onclick="bulkAssignExistingTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80">
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i> <i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
Production <?= htmlspecialchars($tag['name']) ?>
</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> </button>
<?php endforeach; ?>
</div> </div>
<div class="border-t border-gray-200 pt-2"> <div class="border-t border-gray-200 pt-2">
<button type="button" onclick="openTagSelector()" class="w-full px-3 py-1.5 bg-blue-100 text-blue-700 text-xs rounded hover:bg-blue-200 font-medium">
<i class="fas fa-plus mr-1"></i>
Add Custom Tag
</button>
</div>
</div>
<!-- Remove Tags Section -->
<div class="border-t border-gray-200 pt-3">
<label class="block text-xs font-medium text-gray-700 mb-2">Remove Tags from Selected Domains</label>
<div class="space-y-2">
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded hover:bg-gray-200 font-medium"> <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> <i class="fas fa-times mr-1"></i>
Remove All Tags Remove All Tags
</button> </button>
<button type="button" onclick="openTagRemovalSelector()" class="w-full px-3 py-1.5 bg-red-100 text-red-700 text-xs rounded hover:bg-red-200 font-medium">
<i class="fas fa-minus mr-1"></i>
Remove Specific Tag
</button>
</div>
</div> </div>
</div> </div>
<div class="border-t border-gray-200 p-2"> <div class="border-t border-gray-200 p-2">
@@ -320,19 +333,13 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<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>
<div class="flex items-center gap-1.5 mt-1"> <div class="flex items-center gap-1.5 mt-1">
<?php <?php
// Display tags (temporary hardcoded for UI demo - will be dynamic later) // Display tags using new tag system
$tags = !empty($domain['tags']) ? explode(',', $domain['tags']) : []; $tags = !empty($domain['tags']) ? explode(',', $domain['tags']) : [];
$tagColors = [ $tagColors = !empty($domain['tag_colors']) ? explode('|', $domain['tag_colors']) : [];
'production' => 'bg-green-100 text-green-700 border-green-200',
'staging' => 'bg-yellow-100 text-yellow-700 border-yellow-200', foreach ($tags as $index => $tag):
'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); $tag = trim($tag);
$colorClass = $tagColors[$tag] ?? 'bg-gray-100 text-gray-700 border-gray-200'; $colorClass = isset($tagColors[$index]) ? $tagColors[$index] : '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 ?>"> <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> <i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
@@ -412,7 +419,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt"></i>
</button> </button>
</form> </form>
<a href="/domains/<?= $domain['id'] ?>/edit" class="text-yellow-600 hover:text-yellow-800" title="Edit"> <a href="/domains/<?= $domain['id'] ?>/edit?from=/domains" class="text-yellow-600 hover:text-yellow-800" title="Edit">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</a> </a>
<form method="POST" action="/domains/<?= $domain['id'] ?>/delete" class="inline" onsubmit="return confirm('Delete this domain?')"> <form method="POST" action="/domains/<?= $domain['id'] ?>/delete" class="inline" onsubmit="return confirm('Delete this domain?')">
@@ -830,6 +837,237 @@ document.addEventListener('click', function(event) {
} }
}); });
// Tags are now loaded server-side, no need for fetch()
// Bulk assign existing tag to domains
function bulkAssignExistingTag(tagId, 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-assign-existing-tag';
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '<?= csrf_token() ?>';
form.appendChild(csrfInput);
// Add tag ID
const tagInput = document.createElement('input');
tagInput.type = 'hidden';
tagInput.name = 'tag_id';
tagInput.value = tagId;
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();
}
// Open tag selector modal for custom tags
function openTagSelector() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
// Create modal for tag selection
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 z-50';
modal.innerHTML = `
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Add Custom Tag</h3>
</div>
<div class="px-6 py-4">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Tag Name</label>
<input type="text" id="custom-tag-name" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Enter tag name">
<p class="text-xs text-gray-500 mt-1">Use only letters, numbers, and hyphens</p>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 flex justify-end space-x-3">
<button type="button" onclick="closeTagSelector()" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Cancel
</button>
<button type="button" onclick="submitCustomTag()" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
Add Tag
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById('custom-tag-name').focus();
}
function closeTagSelector() {
const modal = document.querySelector('.fixed.inset-0.bg-gray-600');
if (modal) {
modal.remove();
}
}
function submitCustomTag() {
const tagName = document.getElementById('custom-tag-name').value.trim();
if (!tagName) {
alert('Please enter a tag name');
return;
}
if (!/^[a-z0-9-]+$/.test(tagName)) {
alert('Invalid tag name format (use only letters, numbers, and hyphens)');
return;
}
bulkAddTag(tagName);
closeTagSelector();
}
// Open tag removal selector
function openTagRemovalSelector() {
const ids = getSelectedIds();
if (ids.length === 0) {
alert('Please select at least one domain');
return;
}
// Create modal for tag removal
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-gray-600 bg-opacity-50 z-50';
modal.innerHTML = `
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Remove Specific Tag</h3>
</div>
<div class="px-6 py-4">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Select Tag to Remove</label>
<select id="tag-to-remove" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Loading tags...</option>
</select>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 flex justify-end space-x-3">
<button type="button" onclick="closeTagRemovalSelector()" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Cancel
</button>
<button type="button" onclick="submitTagRemoval()" class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
Remove Tag
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
// Load tags for removal (only tags assigned to selected domains)
const select = document.getElementById('tag-to-remove');
select.innerHTML = '<option value="">Loading tags...</option>';
// Fetch tags that are actually assigned to the selected domains
fetch('/domains/get-tags-for-domains', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain_ids: ids,
csrf_token: '<?= csrf_token() ?>'
})
})
.then(response => response.json())
.then(data => {
select.innerHTML = '<option value="">Select a tag to remove</option>';
if (data.tags && data.tags.length > 0) {
data.tags.forEach(tag => {
const option = document.createElement('option');
option.value = tag.id;
option.textContent = tag.name;
select.appendChild(option);
});
} else {
select.innerHTML = '<option value="">No tags found on selected domains</option>';
}
})
.catch(error => {
console.error('Error loading tags:', error);
select.innerHTML = '<option value="">Error loading tags</option>';
});
}
function closeTagRemovalSelector() {
const modal = document.querySelector('.fixed.inset-0.bg-gray-600');
if (modal) {
modal.remove();
}
}
function submitTagRemoval() {
const tagId = document.getElementById('tag-to-remove').value;
if (!tagId) {
alert('Please select a tag to remove');
return;
}
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-remove-specific-tag';
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '<?= csrf_token() ?>';
form.appendChild(csrfInput);
// Add tag ID
const tagInput = document.createElement('input');
tagInput.type = 'hidden';
tagInput.name = 'tag_id';
tagInput.value = tagId;
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();
}
// Tags are loaded server-side, no need for DOMContentLoaded
</script> </script>
<?php <?php

View File

@@ -40,17 +40,18 @@ ob_start();
<!-- Tags Display --> <!-- Tags Display -->
<?php <?php
$tags = !empty($domain['tags']) ? explode(',', $domain['tags']) : []; $tags = !empty($domain['tags']) ? explode(',', $domain['tags']) : [];
$tagColors = [ $tagColors = !empty($domain['tag_colors']) ? explode('|', $domain['tag_colors']) : [];
'production' => 'bg-green-100 text-green-700 border-green-200',
'staging' => 'bg-yellow-100 text-yellow-700 border-yellow-200', // Create a mapping of tag names to their colors
'development' => 'bg-blue-100 text-blue-700 border-blue-200', $tagColorMap = [];
'client' => 'bg-purple-100 text-purple-700 border-purple-200', foreach ($availableTags as $availableTag) {
'personal' => 'bg-orange-100 text-orange-700 border-orange-200', $tagColorMap[$availableTag['name']] = $availableTag['color'];
'archived' => 'bg-gray-100 text-gray-600 border-gray-200' }
];
foreach ($tags as $tag): foreach ($tags as $index => $tag):
$tag = trim($tag); $tag = trim($tag);
$colorClass = $tagColors[$tag] ?? 'bg-gray-100 text-gray-700 border-gray-200'; // Use the color from the database if available, otherwise use the stored color, otherwise default
$colorClass = $tagColorMap[$tag] ?? (isset($tagColors[$index]) ? $tagColors[$index] : 'bg-gray-100 text-gray-700 border-gray-200');
?> ?>
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold border <?= $colorClass ?>"> <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> <i class="fas fa-tag mr-1.5" style="font-size: 10px;"></i>
@@ -66,7 +67,7 @@ ob_start();
Refresh Refresh
</button> </button>
</form> </form>
<a href="/domains/<?= $domain['id'] ?>/edit" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]"> <a href="/domains/<?= $domain['id'] ?>/edit?from=/domains/<?= $domain['id'] ?>" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-edit mr-1.5"></i> <i class="fas fa-edit mr-1.5"></i>
Edit Edit
</a> </a>
@@ -307,7 +308,7 @@ ob_start();
<p class="text-xs text-gray-600 mt-0.5">Won't receive notifications</p> <p class="text-xs text-gray-600 mt-0.5">Won't receive notifications</p>
</div> </div>
</div> </div>
<a href="/domains/<?= $domain['id'] ?>/edit" class="block w-full text-center px-3 py-1.5 bg-orange-500 text-white text-xs rounded-lg hover:bg-orange-600 transition-colors font-medium"> <a href="/domains/<?= $domain['id'] ?>/edit?from=/domains/<?= $domain['id'] ?>" class="block w-full text-center px-3 py-1.5 bg-orange-500 text-white text-xs rounded-lg hover:bg-orange-600 transition-colors font-medium">
<i class="fas fa-plus mr-1"></i> <i class="fas fa-plus mr-1"></i>
Assign Group Assign Group
</a> </a>

View File

@@ -37,6 +37,11 @@
<span class="ml-auto text-xs bg-gray-700 px-1.5 py-0.5 rounded text-gray-400">View</span> <span class="ml-auto text-xs bg-gray-700 px-1.5 py-0.5 rounded text-gray-400">View</span>
<?php endif; ?> <?php endif; ?>
</a> </a>
<a href="/tags" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/tags') !== false ? 'bg-primary text-white' : '' ?>">
<i class="fas fa-tags text-xs mr-3 w-4"></i>
<span class="text-sm">Tag Management</span>
</a>
</div> </div>
<!-- Tools Section --> <!-- Tools Section -->

626
app/Views/tags/index.php Normal file
View File

@@ -0,0 +1,626 @@
<?php
$title = 'Tag Management';
$pageTitle = 'Tag Management';
$pageDescription = 'Manage your domain tags, colors, and organization';
$pageIcon = 'fas fa-tags';
ob_start();
// Helper function to generate sort URL
function sortUrl($column, $currentSort, $currentOrder) {
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
$params = $_GET;
$params['sort'] = $column;
$params['order'] = $newOrder;
return '/tags?' . http_build_query($params);
}
// Helper function for sort icon
function sortIcon($column, $currentSort, $currentOrder) {
if ($currentSort !== $column) {
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
}
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
}
// Get current filters
$currentFilters = $filters ?? ['search' => '', 'color' => '', 'type' => '', 'sort' => 'name', 'order' => 'asc'];
?>
<!-- Action Buttons -->
<div class="mb-4 flex gap-2 justify-end">
<button onclick="openCreateModal()" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-plus mr-2"></i>
Create New Tag
</button>
</div>
<!-- Filters & Search -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<form method="GET" action="/tags" id="filter-form">
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search</label>
<div class="relative">
<input type="text" name="search" id="tagSearch" value="<?= htmlspecialchars($currentFilters['search'] ?? '') ?>" placeholder="Search tags..." class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Color</label>
<select name="color" id="colorFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">All Colors</option>
<?php foreach ($availableColors as $colorValue => $colorName): ?>
<option value="<?= htmlspecialchars($colorValue) ?>" <?= ($currentFilters['color'] ?? '') === $colorValue ? 'selected' : '' ?>><?= htmlspecialchars($colorName) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Type</label>
<select name="type" id="typeFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">All Types</option>
<option value="global" <?= ($currentFilters['type'] ?? '') === 'global' ? 'selected' : '' ?>>Global Tags</option>
<option value="user" <?= ($currentFilters['type'] ?? '') === 'user' ? 'selected' : '' ?>>My Tags</option>
</select>
</div>
<div class="flex items-end space-x-2">
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-filter mr-2"></i>
Apply
</button>
<a href="/tags" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
<i class="fas fa-times"></i>
Clear
</a>
</div>
</div>
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort'] ?? 'name') ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order'] ?? 'asc') ?>">
</form>
</div>
<!-- Pagination Info & Per Page Selector -->
<div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600">
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?? 1 ?></span> to
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?? count($tags) ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?? count($tags) ?></span> tag(s)
</div>
<form method="GET" action="/tags" class="flex items-center gap-2">
<!-- Preserve current filters -->
<input type="hidden" name="search" value="<?= htmlspecialchars($currentFilters['search'] ?? '') ?>">
<input type="hidden" name="color" value="<?= htmlspecialchars($currentFilters['color'] ?? '') ?>">
<input type="hidden" name="type" value="<?= htmlspecialchars($currentFilters['type'] ?? '') ?>">
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort'] ?? 'name') ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order'] ?? 'asc') ?>">
<label for="per_page" class="text-sm text-gray-600">Show:</label>
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="10" <?= ($pagination['per_page'] ?? 25) == 10 ? 'selected' : '' ?>>10</option>
<option value="25" <?= ($pagination['per_page'] ?? 25) == 25 ? 'selected' : '' ?>>25</option>
<option value="50" <?= ($pagination['per_page'] ?? 25) == 50 ? 'selected' : '' ?>>50</option>
<option value="100" <?= ($pagination['per_page'] ?? 25) == 100 ? 'selected' : '' ?>>100</option>
</select>
</form>
</div>
<!-- Bulk Actions Toolbar (Hidden by default, shown when tags are selected) -->
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
<button type="button" onclick="bulkDeleteTags()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash mr-2"></i>
Delete Selected
</button>
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-times mr-2"></i>
Clear Selection
</button>
</div>
</div>
</div>
<!-- Tags List -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<?php if (!empty($tags)): ?>
<!-- Table View (Desktop) -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left">
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 text-primary focus:ring-primary">
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('name', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?>" class="hover:text-primary flex items-center">
Tag <?= sortIcon('name', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('description', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?>" class="hover:text-primary flex items-center">
Description <?= sortIcon('description', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('usage_count', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?>" class="hover:text-primary flex items-center">
Usage <?= sortIcon('usage_count', $currentFilters['sort'] ?? 'name', $currentFilters['order'] ?? 'asc') ?>
</a>
</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($tags as $tag): ?>
<tr class="hover:bg-gray-50 transition-colors duration-150 tag-row">
<td class="px-6 py-4">
<input type="checkbox" class="tag-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= $tag['id'] ?>" onchange="updateBulkActions()">
</td>
<td class="px-6 py-4">
<div class="flex items-center">
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?>">
<i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
<?= htmlspecialchars($tag['name']) ?>
</span>
<?php if ($tag['user_id'] === null): ?>
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<i class="fas fa-globe mr-1" style="font-size: 8px;"></i>
Global
</span>
<?php endif; ?>
</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900">
<?= !empty($tag['description']) ? htmlspecialchars($tag['description']) : '<span class="text-gray-400 italic">No description</span>' ?>
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center text-sm text-gray-500">
<i class="fas fa-link mr-1"></i>
<?= $tag['usage_count'] ?> domain<?= $tag['usage_count'] !== 1 ? 's' : '' ?>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2">
<a href="/tags/<?= $tag['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i>
</a>
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?>
<!-- Global tag - only admins can edit/delete -->
<span class="text-xs text-gray-500 italic">Global tag</span>
<?php else: ?>
<button onclick="openEditModal(<?= htmlspecialchars(json_encode($tag)) ?>)"
class="text-yellow-600 hover:text-yellow-800" title="Edit">
<i class="fas fa-edit"></i>
</button>
<button onclick="deleteTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
class="text-red-600 hover:text-red-800" title="Delete">
<i class="fas fa-trash"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Card View (Mobile) -->
<div class="lg:hidden divide-y divide-gray-200">
<?php foreach ($tags as $tag): ?>
<div class="p-6 hover:bg-gray-50 transition-colors duration-150">
<div class="flex items-center mb-3">
<input type="checkbox" class="tag-checkbox-mobile rounded border-gray-300 text-primary focus:ring-primary mr-3" value="<?= $tag['id'] ?>" onchange="updateBulkActions()">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?>">
<i class="fas fa-tag mr-1" style="font-size: 9px;"></i>
<?= htmlspecialchars($tag['name']) ?>
</span>
<?php if ($tag['user_id'] === null): ?>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<i class="fas fa-globe mr-1" style="font-size: 8px;"></i>
Global
</span>
<?php endif; ?>
</div>
<?php if (!empty($tag['description'])): ?>
<p class="text-sm text-gray-600 mb-2"><?= htmlspecialchars($tag['description']) ?></p>
<?php endif; ?>
<div class="flex items-center text-sm text-gray-500">
<i class="fas fa-link mr-1"></i>
Used on <?= $tag['usage_count'] ?> domain<?= $tag['usage_count'] !== 1 ? 's' : '' ?>
</div>
</div>
<div class="flex items-center space-x-2">
<a href="/tags/<?= $tag['id'] ?>"
class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i>
</a>
<?php if ($tag['user_id'] === null && !\Core\Auth::isAdmin()): ?>
<!-- Global tag - only admins can edit/delete -->
<span class="text-xs text-gray-500 italic">Global tag</span>
<?php else: ?>
<button onclick="openEditModal(<?= htmlspecialchars(json_encode($tag)) ?>)"
class="text-yellow-600 hover:text-yellow-800" title="Edit">
<i class="fas fa-edit"></i>
</button>
<button onclick="deleteTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
class="text-red-600 hover:text-red-800" title="Delete">
<i class="fas fa-trash"></i>
</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="text-center py-12 px-6">
<div class="mb-4">
<i class="fas fa-tags text-gray-300 text-6xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Tags Yet</h3>
<p class="text-sm text-gray-500 mb-4">Start organizing your domains by creating your first tag</p>
<button onclick="openCreateModal()" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-plus mr-2"></i>
<span>Create Your First Tag</span>
</button>
</div>
<?php endif; ?>
</div>
<!-- Pagination Controls -->
<?php if (($pagination['total_pages'] ?? 1) > 1): ?>
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<!-- Page Info -->
<div class="text-sm text-gray-600">
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?? 1 ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?? 1 ?></span>
</div>
<!-- Pagination Buttons -->
<div class="flex items-center gap-1">
<?php
$currentPage = $pagination['current_page'] ?? 1;
$totalPages = $pagination['total_pages'] ?? 1;
// Helper function to build pagination URL
function paginationUrl($page, $filters, $perPage) {
$params = $filters;
$params['page'] = $page;
$params['per_page'] = $perPage;
return '/tags?' . http_build_query($params);
}
?>
<!-- First Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl(1, $currentFilters ?? [], $pagination['per_page'] ?? 25) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-left"></i>
</a>
<?php endif; ?>
<!-- Previous Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl($currentPage - 1, $currentFilters ?? [], $pagination['per_page'] ?? 25) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-left"></i> Previous
</a>
<?php endif; ?>
<!-- Page Numbers -->
<?php
$range = 2; // Show 2 pages on each side of current page
$start = max(1, $currentPage - $range);
$end = min($totalPages, $currentPage + $range);
// Show first page + ellipsis if needed
if ($start > 1) {
echo '<a href="' . paginationUrl(1, $currentFilters ?? [], $pagination['per_page'] ?? 25) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
if ($start > 2) {
echo '<span class="px-2 text-gray-500">...</span>';
}
}
// Page numbers
for ($i = $start; $i <= $end; $i++) {
if ($i == $currentPage) {
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
} else {
echo '<a href="' . paginationUrl($i, $currentFilters ?? [], $pagination['per_page'] ?? 25) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
}
}
// Show last page + ellipsis if needed
if ($end < $totalPages) {
if ($end < $totalPages - 1) {
echo '<span class="px-2 text-gray-500">...</span>';
}
echo '<a href="' . paginationUrl($totalPages, $currentFilters ?? [], $pagination['per_page'] ?? 25) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
}
?>
<!-- Next Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($currentPage + 1, $currentFilters ?? [], $pagination['per_page'] ?? 25) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
Next <i class="fas fa-angle-right"></i>
</a>
<?php endif; ?>
<!-- Last Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($totalPages, $currentFilters ?? [], $pagination['per_page'] ?? 25) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-right"></i>
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Create Tag Modal -->
<div id="createModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full">
<form method="POST" action="/tags/create">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Create New Tag</h3>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label for="create_name" class="block text-sm font-medium text-gray-700 mb-1">Tag Name</label>
<input type="text" id="create_name" name="name" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., production, staging">
<p class="text-xs text-gray-500 mt-1">Use only letters, numbers, and hyphens</p>
</div>
<div>
<label for="create_color" class="block text-sm font-medium text-gray-700 mb-1">Color</label>
<select id="create_color" name="color"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<?php foreach ($availableColors as $colorValue => $colorName): ?>
<option value="<?= htmlspecialchars($colorValue) ?>"><?= htmlspecialchars($colorName) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="create_description" class="block text-sm font-medium text-gray-700 mb-1">Description (Optional)</label>
<textarea id="create_description" name="description" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Describe what this tag is used for"></textarea>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 flex justify-end space-x-3">
<button type="button" onclick="closeCreateModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
Create Tag
</button>
</div>
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
</form>
</div>
</div>
</div>
<!-- Edit Tag Modal -->
<div id="editModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full">
<form method="POST" action="/tags/update">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Edit Tag</h3>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label for="edit_name" class="block text-sm font-medium text-gray-700 mb-1">Tag Name</label>
<input type="text" id="edit_name" name="name" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label for="edit_color" class="block text-sm font-medium text-gray-700 mb-1">Color</label>
<select id="edit_color" name="color"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<?php foreach ($availableColors as $colorValue => $colorName): ?>
<option value="<?= htmlspecialchars($colorValue) ?>"><?= htmlspecialchars($colorName) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label for="edit_description" class="block text-sm font-medium text-gray-700 mb-1">Description (Optional)</label>
<textarea id="edit_description" name="description" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 flex justify-end space-x-3">
<button type="button" onclick="closeEditModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
Update Tag
</button>
</div>
<input type="hidden" name="id" id="edit_id">
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
</form>
</div>
</div>
</div>
<script>
// Multi-select functionality
function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.tag-checkbox, .tag-checkbox-mobile');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBulkActions();
}
function updateBulkActions() {
const checkboxes = document.querySelectorAll('.tag-checkbox:checked, .tag-checkbox-mobile:checked');
const bulkActions = document.getElementById('bulk-actions');
const selectedCount = document.getElementById('selected-count');
const selectAllCheckbox = document.getElementById('select-all');
// Get unique tag IDs (avoid counting both desktop and mobile checkboxes)
const uniqueIds = new Set(Array.from(checkboxes).map(cb => cb.value));
const count = uniqueIds.size;
if (count > 0) {
bulkActions.classList.remove('hidden');
bulkActions.classList.add('flex');
selectedCount.textContent = `${count} tag(s) selected`;
} else {
bulkActions.classList.add('hidden');
bulkActions.classList.remove('flex');
}
// Update select all checkbox state
// Only count desktop checkboxes to avoid double counting
const allCheckboxes = document.querySelectorAll('.tag-checkbox');
const checkedDesktopBoxes = document.querySelectorAll('.tag-checkbox:checked');
if (selectAllCheckbox) {
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkedDesktopBoxes.length === allCheckboxes.length;
selectAllCheckbox.indeterminate = checkedDesktopBoxes.length > 0 && checkedDesktopBoxes.length < allCheckboxes.length;
}
}
function clearSelection() {
const checkboxes = document.querySelectorAll('.tag-checkbox, .tag-checkbox-mobile');
checkboxes.forEach(cb => {
cb.checked = false;
});
document.getElementById('select-all').checked = false;
updateBulkActions();
}
function getSelectedIds() {
const checkboxes = document.querySelectorAll('.tag-checkbox:checked, .tag-checkbox-mobile:checked');
// Return unique IDs only (avoid duplicates from desktop and mobile views)
const ids = Array.from(checkboxes).map(cb => cb.value);
return [...new Set(ids)];
}
function bulkDeleteTags() {
const ids = getSelectedIds();
if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} tag(s)? This will remove them from all domains.`)) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/tags/bulk-delete';
// Add CSRF token
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '<?= csrf_token() ?>';
form.appendChild(csrfInput);
ids.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'tag_ids[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
function openCreateModal() {
document.getElementById('createModal').classList.remove('hidden');
document.getElementById('create_name').focus();
}
function closeCreateModal() {
document.getElementById('createModal').classList.add('hidden');
document.querySelector('#createModal form').reset();
}
function openEditModal(tag) {
document.getElementById('edit_id').value = tag.id;
document.getElementById('edit_name').value = tag.name;
document.getElementById('edit_color').value = tag.color;
document.getElementById('edit_description').value = tag.description || '';
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('edit_name').focus();
}
function closeEditModal() {
document.getElementById('editModal').classList.add('hidden');
}
function deleteTag(id, name) {
if (confirm(`Are you sure you want to delete the tag "${name}"? This will remove it from all domains.`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/tags/delete';
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = 'id';
idInput.value = id;
form.appendChild(idInput);
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '<?= csrf_token() ?>';
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
}
}
// Close modals on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeEditModal();
}
});
// Close modals on backdrop click
document.getElementById('createModal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateModal();
}
});
document.getElementById('editModal').addEventListener('click', function(e) {
if (e.target === this) {
closeEditModal();
}
});
</script>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

414
app/Views/tags/view.php Normal file
View File

@@ -0,0 +1,414 @@
<?php
$title = 'Tag: ' . htmlspecialchars($tag['name']);
$pageTitle = 'Tag: ' . htmlspecialchars($tag['name']);
$pageDescription = 'View all domains that have this tag assigned';
$pageIcon = 'fas fa-tag';
ob_start();
// Helper function to generate sort URL
function sortUrl($column, $currentSort, $currentOrder, $tagId) {
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
$params = $_GET;
$params['sort'] = $column;
$params['order'] = $newOrder;
return '/tags/' . $tagId . '?' . http_build_query($params);
}
// Helper function for sort icon
function sortIcon($column, $currentSort, $currentOrder) {
if ($currentSort !== $column) {
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
}
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
}
// Get current filters
$currentFilters = $filters ?? ['search' => '', 'status' => '', 'registrar' => '', 'sort' => 'domain_name', 'order' => 'asc'];
?>
<!-- Back Navigation -->
<div class="mb-4">
<a href="/tags" class="inline-flex items-center px-3 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-arrow-left mr-2"></i>
Back to Tags
</a>
</div>
<!-- Tag Info Card -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium border <?= htmlspecialchars($tag['color']) ?>">
<i class="fas fa-tag mr-1"></i>
<?= htmlspecialchars($tag['name']) ?>
</span>
</div>
<div class="ml-3">
<h3 class="text-sm font-semibold text-gray-900 mb-1">Tag Description</h3>
<p class="text-xs text-gray-600 leading-relaxed">
<?php if (!empty($tag['description'])): ?>
<?= htmlspecialchars($tag['description']) ?>
<?php endif; ?>
</p>
</div>
</div>
</div>
<!-- Filters & Search -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<form method="GET" action="/tags/<?= $tag['id'] ?>" id="filter-form">
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search</label>
<div class="relative">
<input type="text" name="search" id="domainSearch" value="<?= htmlspecialchars($currentFilters['search']) ?>" placeholder="Search domains..." class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
<select name="status" id="statusFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">All Statuses</option>
<option value="active" <?= $currentFilters['status'] === 'active' ? 'selected' : '' ?>>Active</option>
<option value="expiring_soon" <?= $currentFilters['status'] === 'expiring_soon' ? 'selected' : '' ?>>Expiring Soon</option>
<option value="available" <?= $currentFilters['status'] === 'available' ? 'selected' : '' ?>>Available</option>
<option value="error" <?= $currentFilters['status'] === 'error' ? 'selected' : '' ?>>Error</option>
<option value="inactive" <?= $currentFilters['status'] === 'inactive' ? 'selected' : '' ?>>Inactive</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Registrar</label>
<select name="registrar" id="registrarFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<option value="">All Registrars</option>
<?php
$registrars = array_unique(array_column($domains, 'registrar'));
$registrars = array_filter($registrars);
foreach ($registrars as $registrar):
?>
<option value="<?= htmlspecialchars($registrar) ?>" <?= ($currentFilters['registrar'] ?? '') === $registrar ? 'selected' : '' ?>><?= htmlspecialchars($registrar) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-end space-x-2">
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-filter mr-2"></i>
Apply
</button>
<a href="/tags/<?= $tag['id'] ?>" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
<i class="fas fa-times"></i>
Clear
</a>
</div>
</div>
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
</form>
</div>
<!-- Pagination Info & Per Page Selector -->
<div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600">
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?? 1 ?></span> to
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?? count($domains) ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?? count($domains) ?></span> domain(s)
</div>
<form method="GET" action="/tags/<?= $tag['id'] ?>" class="flex items-center gap-2">
<!-- Preserve current filters -->
<input type="hidden" name="search" value="<?= htmlspecialchars($currentFilters['search']) ?>">
<input type="hidden" name="status" value="<?= htmlspecialchars($currentFilters['status']) ?>">
<input type="hidden" name="registrar" value="<?= htmlspecialchars($currentFilters['registrar']) ?>">
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
<label for="per_page" class="text-sm text-gray-600">Show:</label>
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="10" <?= ($pagination['per_page'] ?? 25) == 10 ? 'selected' : '' ?>>10</option>
<option value="25" <?= ($pagination['per_page'] ?? 25) == 25 ? 'selected' : '' ?>>25</option>
<option value="50" <?= ($pagination['per_page'] ?? 25) == 50 ? 'selected' : '' ?>>50</option>
<option value="100" <?= ($pagination['per_page'] ?? 25) == 100 ? 'selected' : '' ?>>100</option>
</select>
</form>
</div>
<!-- Domains List -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<?php if (!empty($domains)): ?>
<!-- Table View (Desktop) -->
<div class="hidden lg:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('domain_name', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
Domain <?= sortIcon('domain_name', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('registrar', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
Registrar <?= sortIcon('registrar', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('expiration_date', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
Expiration <?= sortIcon('expiration_date', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('status', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
Status <?= sortIcon('status', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('last_checked', $currentFilters['sort'], $currentFilters['order'], $tag['id']) ?>" class="hover:text-primary flex items-center">
Last Checked <?= sortIcon('last_checked', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($domains as $domain): ?>
<?php
// Display data prepared by DomainHelper in controller
$daysLeft = $domain['daysLeft'];
$expiryClass = $domain['expiryClass'];
$statusClass = $domain['statusClass'];
$statusText = $domain['statusText'];
$statusIcon = $domain['statusIcon'];
?>
<tr class="hover:bg-gray-50 transition-colors duration-150">
<td class="px-6 py-4">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<i class="fas fa-globe text-primary"></i>
</div>
<div class="ml-4">
<a href="/domains/<?= $domain['id'] ?>" class="text-sm font-semibold text-gray-900 hover:text-primary"><?= htmlspecialchars($domain['domain_name']) ?></a>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<?php if (!empty($domain['registrar'])): ?>
<div class="flex items-center">
<i class="fas fa-building text-gray-400 mr-2"></i>
<span class="text-sm text-gray-900"><?= htmlspecialchars($domain['registrar']) ?></span>
</div>
<?php else: ?>
<span class="text-sm text-gray-400">Unknown</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<?php if (!empty($domain['expiration_date'])): ?>
<div class="text-sm">
<div class="font-medium text-gray-900 flex items-center">
<?= date('M d, Y', strtotime($domain['expiration_date'])) ?>
</div>
<div class="text-xs <?= $expiryClass ?>">
<?= $daysLeft ?> days
</div>
</div>
<?php else: ?>
<span class="text-sm text-gray-400">Not set</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border <?= $statusClass ?>">
<i class="fas <?= $statusIcon ?> mr-1"></i>
<?= $statusText ?>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<?php if (!empty($domain['last_checked'])): ?>
<div class="flex items-center">
<i class="far fa-clock mr-2"></i>
<?= date('M d, H:i', strtotime($domain['last_checked'])) ?>
</div>
<?php else: ?>
<span class="text-gray-400">Never</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2">
<a href="/domains/<?= $domain['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i>
</a>
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="inline">
<?= csrf_field() ?>
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
<i class="fas fa-sync-alt"></i>
</button>
</form>
<a href="/domains/<?= $domain['id'] ?>/edit?from=/tags/<?= $tag['id'] ?>" class="text-yellow-600 hover:text-yellow-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Card View (Mobile) -->
<div class="lg:hidden divide-y divide-gray-200">
<?php foreach ($domains as $domain): ?>
<?php
// Display data prepared by DomainHelper in controller
$daysLeft = $domain['daysLeft'];
$expiryClass = $domain['expiryClass'];
$statusClass = $domain['statusClass'];
$statusText = $domain['statusText'];
$statusIcon = $domain['statusIcon'];
?>
<div class="p-6 hover:bg-gray-50 transition-colors duration-150">
<div class="flex items-center justify-between mb-3">
<a href="/domains/<?= $domain['id'] ?>" class="text-lg font-semibold text-gray-900 hover:text-primary"><?= htmlspecialchars($domain['domain_name']) ?></a>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border <?= $statusClass ?>">
<i class="fas <?= $statusIcon ?> mr-1"></i>
<?= $statusText ?>
</span>
</div>
<div class="space-y-2 text-sm">
<?php if (!empty($domain['registrar'])): ?>
<div class="flex items-center">
<i class="fas fa-building text-gray-400 mr-2 w-4"></i>
<span><?= htmlspecialchars($domain['registrar']) ?></span>
</div>
<?php endif; ?>
<?php if (!empty($domain['expiration_date'])): ?>
<div class="flex items-center">
<i class="fas fa-calendar-alt text-gray-400 mr-2 w-4"></i>
<span>Expires: <?= date('M d, Y', strtotime($domain['expiration_date'])) ?> (<?= $daysLeft ?> days)</span>
</div>
<?php endif; ?>
<div class="flex items-center">
<i class="far fa-clock text-gray-400 mr-2 w-4"></i>
<span><?= $domain['last_checked'] ? date('M d, H:i', strtotime($domain['last_checked'])) : 'Never checked' ?></span>
</div>
</div>
<div class="flex space-x-2 mt-3">
<a href="/domains/<?= $domain['id'] ?>" class="flex-1 px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors">
<i class="fas fa-eye mr-1"></i> View
</a>
<form method="POST" action="/domains/<?= $domain['id'] ?>/refresh" class="flex-1">
<?= csrf_field() ?>
<button type="submit" class="w-full px-3 py-1.5 bg-green-50 text-green-600 rounded text-center text-sm hover:bg-green-100 transition-colors">
<i class="fas fa-sync-alt mr-1"></i> Refresh
</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="text-center py-12 px-6">
<div class="mb-4">
<i class="fas fa-globe text-gray-300 text-6xl"></i>
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Domains Found</h3>
<p class="text-sm text-gray-500 mb-4">This tag is not currently assigned to any domains</p>
<a href="/domains" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-plus mr-2"></i>
<span>Add Domains</span>
</a>
</div>
<?php endif; ?>
</div>
<!-- Pagination Controls -->
<?php if (($pagination['total_pages'] ?? 1) > 1): ?>
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<!-- Page Info -->
<div class="text-sm text-gray-600">
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?? 1 ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?? 1 ?></span>
</div>
<!-- Pagination Buttons -->
<div class="flex items-center gap-1">
<?php
$currentPage = $pagination['current_page'] ?? 1;
$totalPages = $pagination['total_pages'] ?? 1;
// Helper function to build pagination URL
function paginationUrl($page, $filters, $perPage, $tagId) {
$params = $filters;
$params['page'] = $page;
$params['per_page'] = $perPage;
return '/tags/' . $tagId . '?' . http_build_query($params);
}
?>
<!-- First Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl(1, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-left"></i>
</a>
<?php endif; ?>
<!-- Previous Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl($currentPage - 1, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-left"></i> Previous
</a>
<?php endif; ?>
<!-- Page Numbers -->
<?php
$range = 2; // Show 2 pages on each side of current page
$start = max(1, $currentPage - $range);
$end = min($totalPages, $currentPage + $range);
// Show first page + ellipsis if needed
if ($start > 1) {
echo '<a href="' . paginationUrl(1, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
if ($start > 2) {
echo '<span class="px-2 text-gray-500">...</span>';
}
}
// Page numbers
for ($i = $start; $i <= $end; $i++) {
if ($i == $currentPage) {
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
} else {
echo '<a href="' . paginationUrl($i, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
}
}
// Show last page + ellipsis if needed
if ($end < $totalPages) {
if ($end < $totalPages - 1) {
echo '<span class="px-2 text-gray-500">...</span>';
}
echo '<a href="' . paginationUrl($totalPages, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
}
?>
<!-- Next Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($currentPage + 1, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
Next <i class="fas fa-angle-right"></i>
</a>
<?php endif; ?>
<!-- Last Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($totalPages, $currentFilters ?? [], $pagination['per_page'] ?? 25, $tag['id']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-right"></i>
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
include __DIR__ . '/../layout/base.php';
?>

View File

@@ -0,0 +1,90 @@
-- Create comprehensive tags system with user isolation support
-- This migration creates the tags table, domain_tags junction table, migrates existing data, and adds user isolation
-- Create tags table for better tag management
CREATE TABLE IF NOT EXISTS tags (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
color VARCHAR(50) DEFAULT 'bg-gray-100 text-gray-700 border-gray-300',
description TEXT NULL,
usage_count INT DEFAULT 0,
user_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_name (name),
INDEX idx_usage_count (usage_count),
INDEX idx_user_id (user_id),
UNIQUE KEY unique_user_tag (user_id, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Create domain_tags junction table for many-to-many relationship
CREATE TABLE IF NOT EXISTS domain_tags (
id INT AUTO_INCREMENT PRIMARY KEY,
domain_id INT NOT NULL,
tag_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
UNIQUE KEY unique_domain_tag (domain_id, tag_id),
INDEX idx_domain_id (domain_id),
INDEX idx_tag_id (tag_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default tags with their colors (global tags)
INSERT INTO tags (name, color, description, user_id) VALUES
('production', 'bg-green-100 text-green-700 border-green-300', 'Production environment domains', NULL),
('staging', 'bg-yellow-100 text-yellow-700 border-yellow-300', 'Staging environment domains', NULL),
('development', 'bg-blue-100 text-blue-700 border-blue-300', 'Development environment domains', NULL),
('client', 'bg-purple-100 text-purple-700 border-purple-300', 'Client-related domains', NULL),
('personal', 'bg-orange-100 text-orange-700 border-orange-300', 'Personal domains', NULL),
('archived', 'bg-gray-100 text-gray-700 border-gray-300', 'Archived or inactive domains', NULL)
ON DUPLICATE KEY UPDATE color = VALUES(color), description = VALUES(description);
-- Migrate existing comma-separated tags to the new tag system
-- Create a temporary table to store the migration data
CREATE TEMPORARY TABLE temp_domain_tags AS
SELECT
d.id as domain_id,
TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(d.tags, ',', n.n), ',', -1)) as tag_name
FROM domains d
CROSS JOIN (
SELECT 1 as n UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5
UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 10
) n
WHERE d.tags IS NOT NULL
AND d.tags != ''
AND CHAR_LENGTH(d.tags) - CHAR_LENGTH(REPLACE(d.tags, ',', '')) >= n.n - 1
AND TRIM(SUBSTRING_INDEX(SUBSTRING_INDEX(d.tags, ',', n.n), ',', -1)) != '';
-- Insert new tags that don't exist yet (assign to domain owner)
-- Note: If the same tag name is used by multiple users, only the first one will be created
-- due to the UNIQUE constraint on (user_id, name). This is intentional to avoid conflicts.
INSERT IGNORE INTO tags (name, color, description, user_id)
SELECT DISTINCT
tdt.tag_name,
'bg-gray-100 text-gray-700 border-gray-300' as color,
CONCAT('Tag: ', tdt.tag_name) as description,
d.user_id
FROM temp_domain_tags tdt
JOIN domains d ON d.id = tdt.domain_id
WHERE tdt.tag_name NOT IN (SELECT name FROM tags);
-- Insert domain-tag relationships
INSERT IGNORE INTO domain_tags (domain_id, tag_id)
SELECT
tdt.domain_id,
t.id as tag_id
FROM temp_domain_tags tdt
JOIN tags t ON t.name = tdt.tag_name;
-- Update usage counts
UPDATE tags t
SET usage_count = (
SELECT COUNT(*)
FROM domain_tags dt
WHERE dt.tag_id = t.id
);
-- Drop the old tags column from domains table
ALTER TABLE domains DROP COLUMN tags;

View File

@@ -16,6 +16,7 @@ use App\Controllers\InstallerController;
use App\Controllers\NotificationController; use App\Controllers\NotificationController;
use App\Controllers\ErrorLogController; use App\Controllers\ErrorLogController;
use App\Controllers\TwoFactorController; use App\Controllers\TwoFactorController;
use App\Controllers\TagController;
$router = Application::$router; $router = Application::$router;
@@ -70,6 +71,9 @@ $router->post('/domains/bulk-assign-group', [DomainController::class, 'bulkAssig
$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-add-tags', [DomainController::class, 'bulkAddTags']);
$router->post('/domains/bulk-remove-tags', [DomainController::class, 'bulkRemoveTags']); $router->post('/domains/bulk-remove-tags', [DomainController::class, 'bulkRemoveTags']);
$router->post('/domains/bulk-remove-specific-tag', [DomainController::class, 'bulkRemoveSpecificTag']);
$router->post('/domains/bulk-assign-existing-tag', [DomainController::class, 'bulkAssignExistingTag']);
$router->post('/domains/get-tags-for-domains', [DomainController::class, 'getTagsForDomains']);
$router->post('/domains/transfer', [DomainController::class, 'transfer']); $router->post('/domains/transfer', [DomainController::class, 'transfer']);
$router->post('/domains/bulk-transfer', [DomainController::class, 'bulkTransfer']); $router->post('/domains/bulk-transfer', [DomainController::class, 'bulkTransfer']);
$router->post('/domains/store', [DomainController::class, 'store']); $router->post('/domains/store', [DomainController::class, 'store']);
@@ -171,3 +175,14 @@ $router->post('/errors/{id}/delete', [ErrorLogController::class, 'delete']);
$router->post('/errors/bulk-delete', [ErrorLogController::class, 'bulkDelete']); $router->post('/errors/bulk-delete', [ErrorLogController::class, 'bulkDelete']);
$router->post('/errors/clear-resolved', [ErrorLogController::class, 'clearResolved']); $router->post('/errors/clear-resolved', [ErrorLogController::class, 'clearResolved']);
// Tag Management
$router->get('/tags', [TagController::class, 'index']);
$router->post('/tags/create', [TagController::class, 'create']);
$router->post('/tags/update', [TagController::class, 'update']);
$router->post('/tags/delete', [TagController::class, 'delete']);
$router->post('/tags/bulk-delete', [TagController::class, 'bulkDelete']);
$router->get('/tags/{id}', [TagController::class, 'show']);
$router->post('/tags/bulk-add-to-domains', [TagController::class, 'bulkAddToDomains']);
$router->post('/tags/bulk-remove-from-domains', [TagController::class, 'bulkRemoveFromDomains']);