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:
@@ -77,6 +77,14 @@ class DomainController extends Controller
|
||||
$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
|
||||
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']);
|
||||
|
||||
@@ -91,6 +99,7 @@ class DomainController extends Controller
|
||||
'domains' => $formattedDomains,
|
||||
'groups' => $groups,
|
||||
'allTags' => $allTags,
|
||||
'availableTags' => $availableTags,
|
||||
'users' => $users,
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
@@ -118,8 +127,17 @@ class DomainController extends Controller
|
||||
$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', [
|
||||
'groups' => $groups,
|
||||
'availableTags' => $availableTags,
|
||||
'title' => 'Add Domain'
|
||||
]);
|
||||
}
|
||||
@@ -237,7 +255,18 @@ class DomainController extends Controller
|
||||
public function edit($params = [])
|
||||
{
|
||||
$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) {
|
||||
$_SESSION['error'] = 'Domain not found';
|
||||
@@ -246,19 +275,28 @@ class DomainController extends Controller
|
||||
}
|
||||
|
||||
// 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') {
|
||||
$groups = $this->groupModel->getAllWithChannelCount($userId);
|
||||
} else {
|
||||
$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', [
|
||||
'domain' => $domain,
|
||||
'groups' => $groups,
|
||||
'availableTags' => $availableTags,
|
||||
'referrer' => $referrer,
|
||||
'title' => 'Edit Domain'
|
||||
]);
|
||||
}
|
||||
@@ -319,7 +357,6 @@ class DomainController extends Controller
|
||||
|
||||
$this->domainModel->update($id, [
|
||||
'notification_group_id' => $groupId,
|
||||
'tags' => $tags,
|
||||
'is_active' => $isActive,
|
||||
'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';
|
||||
$this->redirect('/domains/' . $id);
|
||||
}
|
||||
@@ -471,11 +518,11 @@ class DomainController extends Controller
|
||||
$settingModel = new \App\Models\Setting();
|
||||
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||
|
||||
// Check domain access based on isolation mode
|
||||
// Get domain with tags and groups
|
||||
if ($isolationMode === 'isolated') {
|
||||
$domain = $this->domainModel->getWithChannels($id, $userId);
|
||||
$domain = $this->domainModel->getWithTagsAndGroups($id, $userId);
|
||||
} else {
|
||||
$domain = $this->domainModel->getWithChannels($id);
|
||||
$domain = $this->domainModel->getWithTagsAndGroups($id);
|
||||
}
|
||||
|
||||
if (!$domain) {
|
||||
@@ -503,9 +550,18 @@ class DomainController extends Controller
|
||||
$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', [
|
||||
'domain' => $formattedDomain,
|
||||
'logs' => $logs,
|
||||
'availableTags' => $availableTags,
|
||||
'title' => $domain['domain_name']
|
||||
]);
|
||||
}
|
||||
@@ -524,8 +580,17 @@ class DomainController extends Controller
|
||||
$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', [
|
||||
'groups' => $groups,
|
||||
'availableTags' => $availableTags,
|
||||
'title' => 'Bulk Add Domains'
|
||||
]);
|
||||
return;
|
||||
@@ -1007,6 +1072,7 @@ class DomainController extends Controller
|
||||
$settingModel = new \App\Models\Setting();
|
||||
$isolationMode = $settingModel->getValue('user_isolation_mode', 'shared');
|
||||
|
||||
$tagModel = new \App\Models\Tag();
|
||||
$updated = 0;
|
||||
foreach ($domainIds as $id) {
|
||||
// Check domain access based on isolation mode
|
||||
@@ -1016,7 +1082,7 @@ class DomainController extends Controller
|
||||
$domain = $this->domainModel->find($id);
|
||||
}
|
||||
|
||||
if ($domain && $this->domainModel->update($id, ['tags' => ''])) {
|
||||
if ($domain && $tagModel->removeAllFromDomain($id)) {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
@@ -1025,6 +1091,112 @@ class DomainController extends Controller
|
||||
$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)
|
||||
*/
|
||||
@@ -1125,5 +1297,34 @@ class DomainController extends Controller
|
||||
$_SESSION['success'] = "$transferred domain(s) transferred to {$targetUser['username']}";
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ class InstallerController extends Controller
|
||||
'017_add_two_factor_authentication.sql',
|
||||
'018_add_user_isolation.sql',
|
||||
'019_add_webhook_channel_type.sql',
|
||||
'020_create_tags_system.sql',
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -185,7 +186,8 @@ class InstallerController extends Controller
|
||||
'016_add_tags_to_domains.sql',
|
||||
'017_add_two_factor_authentication.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',
|
||||
'018_add_user_isolation.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");
|
||||
|
||||
@@ -533,7 +533,7 @@ class SettingsController extends Controller
|
||||
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 {
|
||||
// Switching back to shared mode
|
||||
$this->settingModel->setValue('user_isolation_mode', 'shared');
|
||||
@@ -572,6 +572,10 @@ class SettingsController extends Controller
|
||||
$groupModel = new \App\Models\NotificationGroup();
|
||||
$groupCount = $groupModel->assignUnassignedGroupsToUser($adminId);
|
||||
|
||||
// Assign all tags to admin
|
||||
$tagModel = new \App\Models\Tag();
|
||||
$tagCount = $tagModel->assignUnassignedTagsToUser($adminId);
|
||||
|
||||
// Set isolation mode
|
||||
$this->settingModel->setValue('user_isolation_mode', 'isolated');
|
||||
|
||||
@@ -579,7 +583,8 @@ class SettingsController extends Controller
|
||||
'success' => true,
|
||||
'admin_id' => $adminId,
|
||||
'domains_assigned' => $domainCount,
|
||||
'groups_assigned' => $groupCount
|
||||
'groups_assigned' => $groupCount,
|
||||
'tags_assigned' => $tagCount
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
|
||||
492
app/Controllers/TagController.php
Normal file
492
app/Controllers/TagController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -21,16 +21,20 @@ class Domain extends Model
|
||||
*/
|
||||
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
|
||||
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) {
|
||||
$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->execute([$userId]);
|
||||
$stmt->execute([$userId, $userId]);
|
||||
} 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);
|
||||
}
|
||||
|
||||
@@ -301,12 +305,24 @@ class Domain extends Model
|
||||
|
||||
// Apply tag filter
|
||||
if (!empty($filters['tag'])) {
|
||||
$domains = array_filter($domains, function($domain) use ($filters) {
|
||||
if (empty($domain['tags'])) {
|
||||
return false;
|
||||
}
|
||||
$domainTags = array_map('trim', explode(',', $domain['tags']));
|
||||
return in_array($filters['tag'], $domainTags);
|
||||
// Get domain IDs that have the specified tag
|
||||
$tagSql = "SELECT DISTINCT dt.domain_id
|
||||
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;
|
||||
}
|
||||
|
||||
$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
|
||||
{
|
||||
$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 = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$sql .= " ORDER BY t.name";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$results = $stmt->fetchAll();
|
||||
|
||||
$allTags = [];
|
||||
foreach ($results as $row) {
|
||||
if (!empty($row['tags'])) {
|
||||
$tags = array_map('trim', explode(',', $row['tags']));
|
||||
$allTags = array_merge($allTags, $tags);
|
||||
}
|
||||
return array_column($results, 'name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags that are assigned to specific domains
|
||||
*/
|
||||
public function getTagsForDomains(array $domainIds, ?int $userId = null): array
|
||||
{
|
||||
if (empty($domainIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return unique, sorted tags
|
||||
$allTags = array_unique($allTags);
|
||||
sort($allTags);
|
||||
return $allTags;
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
424
app/Models/Tag.php
Normal 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)
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -68,22 +68,17 @@ ob_start();
|
||||
All imported domains will be tagged with these tags. Type any custom tag or use suggestions below.
|
||||
</p>
|
||||
|
||||
<!-- Suggested Tags -->
|
||||
<!-- Available Tags -->
|
||||
<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">
|
||||
<button type="button" onclick="addTag('production')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-green-50 text-green-700 border-green-200 hover:bg-green-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Production
|
||||
</button>
|
||||
<button type="button" onclick="addTag('staging')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Staging
|
||||
</button>
|
||||
<button type="button" onclick="addTag('client')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Client
|
||||
</button>
|
||||
<?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>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,14 +171,12 @@ ob_start();
|
||||
<script>
|
||||
let tags = [];
|
||||
|
||||
const tagColors = {
|
||||
'production': 'bg-green-100 text-green-700 border-green-300',
|
||||
'staging': 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
'development': 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
'client': 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
'personal': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'archived': 'bg-gray-100 text-gray-700 border-gray-300'
|
||||
};
|
||||
// Available tags with their colors from the database
|
||||
const availableTags = <?= json_encode($availableTags) ?>;
|
||||
const tagColors = {};
|
||||
availableTags.forEach(tag => {
|
||||
tagColors[tag.name] = tag.color;
|
||||
});
|
||||
|
||||
function addTag(tagName) {
|
||||
tagName = tagName.trim().toLowerCase();
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
|
||||
<!-- Suggested Tags -->
|
||||
<!-- Available Tags -->
|
||||
<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">
|
||||
<button type="button" onclick="addTag('production')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-green-50 text-green-700 border-green-200 hover:bg-green-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Production
|
||||
</button>
|
||||
<button type="button" onclick="addTag('staging')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Staging
|
||||
</button>
|
||||
<button type="button" onclick="addTag('development')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Development
|
||||
</button>
|
||||
<button type="button" onclick="addTag('client')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Client
|
||||
</button>
|
||||
<button type="button" onclick="addTag('personal')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Personal
|
||||
</button>
|
||||
<?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>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,14 +174,12 @@ ob_start();
|
||||
<script>
|
||||
let tags = [];
|
||||
|
||||
const tagColors = {
|
||||
'production': 'bg-green-100 text-green-700 border-green-300',
|
||||
'staging': 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
'development': 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
'client': 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
'personal': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'archived': 'bg-gray-100 text-gray-700 border-gray-300'
|
||||
};
|
||||
// Available tags with their colors from the database
|
||||
const availableTags = <?= json_encode($availableTags) ?>;
|
||||
const tagColors = {};
|
||||
availableTags.forEach(tag => {
|
||||
tagColors[tag.name] = tag.color;
|
||||
});
|
||||
|
||||
function addTag(tagName) {
|
||||
tagName = tagName.trim().toLowerCase();
|
||||
|
||||
@@ -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.
|
||||
</p>
|
||||
|
||||
<!-- Suggested Tags -->
|
||||
<!-- Available Tags -->
|
||||
<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">
|
||||
<button type="button" onclick="addTag('production')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-green-50 text-green-700 border-green-200 hover:bg-green-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Production
|
||||
</button>
|
||||
<button type="button" onclick="addTag('staging')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Staging
|
||||
</button>
|
||||
<button type="button" onclick="addTag('development')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Development
|
||||
</button>
|
||||
<button type="button" onclick="addTag('client')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Client
|
||||
</button>
|
||||
<button type="button" onclick="addTag('personal')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100 transition-colors">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Personal
|
||||
</button>
|
||||
<?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>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +159,7 @@ ob_start();
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
Update Domain
|
||||
</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">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Cancel
|
||||
@@ -213,14 +200,12 @@ ob_start();
|
||||
const existingTags = '<?= htmlspecialchars($domain['tags'] ?? '') ?>';
|
||||
let tags = existingTags ? existingTags.split(',').map(t => t.trim().toLowerCase()).filter(t => t) : [];
|
||||
|
||||
const tagColors = {
|
||||
'production': 'bg-green-100 text-green-700 border-green-300',
|
||||
'staging': 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
'development': 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
'client': 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
'personal': 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
'archived': 'bg-gray-100 text-gray-700 border-gray-300'
|
||||
};
|
||||
// Available tags with their colors from the database
|
||||
const availableTags = <?= json_encode($availableTags) ?>;
|
||||
const tagColors = {};
|
||||
availableTags.forEach(tag => {
|
||||
tagColors[tag.name] = tag.color;
|
||||
});
|
||||
|
||||
function addTag(tagName) {
|
||||
tagName = tagName.trim().toLowerCase();
|
||||
|
||||
@@ -147,34 +147,47 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
</button>
|
||||
<div id="assign-tags-dropdown" class="hidden absolute left-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<div class="p-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Add Tags to Selected Domains</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<button type="button" onclick="bulkAddTag('production')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-green-50 text-green-700 border-green-200 hover:bg-green-100">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Production
|
||||
</button>
|
||||
<button type="button" onclick="bulkAddTag('staging')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-yellow-50 text-yellow-700 border-yellow-200 hover:bg-yellow-100">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Staging
|
||||
</button>
|
||||
<button type="button" onclick="bulkAddTag('development')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Development
|
||||
</button>
|
||||
<button type="button" onclick="bulkAddTag('client')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-purple-50 text-purple-700 border-purple-200 hover:bg-purple-100">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Client
|
||||
</button>
|
||||
<button type="button" onclick="bulkAddTag('personal')" class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
Personal
|
||||
</button>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<label class="block text-xs font-medium text-gray-700">Tag Management</label>
|
||||
<a href="/tags" class="text-xs text-blue-600 hover:text-blue-800">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Manage Tags
|
||||
</a>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-2">
|
||||
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded hover:bg-gray-200 font-medium">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Remove All Tags
|
||||
</button>
|
||||
|
||||
<!-- Add Tags Section -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Add Tags to Selected Domains</label>
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
<?php foreach ($availableTags as $tag): ?>
|
||||
<button type="button" onclick="bulkAssignExistingTag(<?= $tag['id'] ?>, '<?= htmlspecialchars($tag['name']) ?>')"
|
||||
class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border <?= htmlspecialchars($tag['color']) ?> hover:opacity-80">
|
||||
<i class="fas fa-plus mr-1" style="font-size: 8px;"></i>
|
||||
<?= htmlspecialchars($tag['name']) ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 pt-2">
|
||||
<button type="button" onclick="openTagSelector()" class="w-full px-3 py-1.5 bg-blue-100 text-blue-700 text-xs rounded hover:bg-blue-200 font-medium">
|
||||
<i class="fas fa-plus mr-1"></i>
|
||||
Add Custom Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Tags Section -->
|
||||
<div class="border-t border-gray-200 pt-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-2">Remove Tags from Selected Domains</label>
|
||||
<div class="space-y-2">
|
||||
<button type="button" onclick="bulkRemoveAllTags()" class="w-full px-3 py-1.5 bg-gray-100 text-gray-700 text-xs rounded hover:bg-gray-200 font-medium">
|
||||
<i class="fas fa-times mr-1"></i>
|
||||
Remove All Tags
|
||||
</button>
|
||||
<button type="button" onclick="openTagRemovalSelector()" class="w-full px-3 py-1.5 bg-red-100 text-red-700 text-xs rounded hover:bg-red-200 font-medium">
|
||||
<i class="fas fa-minus mr-1"></i>
|
||||
Remove Specific Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2">
|
||||
@@ -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>
|
||||
<div class="flex items-center gap-1.5 mt-1">
|
||||
<?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']) : [];
|
||||
$tagColors = [
|
||||
'production' => 'bg-green-100 text-green-700 border-green-200',
|
||||
'staging' => 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
'development' => 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
'client' => 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
'personal' => 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
'archived' => 'bg-gray-100 text-gray-600 border-gray-200'
|
||||
];
|
||||
foreach ($tags as $tag):
|
||||
$tagColors = !empty($domain['tag_colors']) ? explode('|', $domain['tag_colors']) : [];
|
||||
|
||||
foreach ($tags as $index => $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 ?>">
|
||||
<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>
|
||||
</button>
|
||||
</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>
|
||||
</a>
|
||||
<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>
|
||||
|
||||
<?php
|
||||
|
||||
@@ -40,17 +40,18 @@ ob_start();
|
||||
<!-- Tags Display -->
|
||||
<?php
|
||||
$tags = !empty($domain['tags']) ? explode(',', $domain['tags']) : [];
|
||||
$tagColors = [
|
||||
'production' => 'bg-green-100 text-green-700 border-green-200',
|
||||
'staging' => 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
'development' => 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
'client' => 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
'personal' => 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
'archived' => 'bg-gray-100 text-gray-600 border-gray-200'
|
||||
];
|
||||
foreach ($tags as $tag):
|
||||
$tagColors = !empty($domain['tag_colors']) ? explode('|', $domain['tag_colors']) : [];
|
||||
|
||||
// Create a mapping of tag names to their colors
|
||||
$tagColorMap = [];
|
||||
foreach ($availableTags as $availableTag) {
|
||||
$tagColorMap[$availableTag['name']] = $availableTag['color'];
|
||||
}
|
||||
|
||||
foreach ($tags as $index => $tag):
|
||||
$tag = trim($tag);
|
||||
$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 ?>">
|
||||
<i class="fas fa-tag mr-1.5" style="font-size: 10px;"></i>
|
||||
@@ -66,7 +67,7 @@ ob_start();
|
||||
Refresh
|
||||
</button>
|
||||
</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>
|
||||
Edit
|
||||
</a>
|
||||
@@ -307,7 +308,7 @@ ob_start();
|
||||
<p class="text-xs text-gray-600 mt-0.5">Won't receive notifications</p>
|
||||
</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>
|
||||
Assign Group
|
||||
</a>
|
||||
|
||||
@@ -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>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
|
||||
<a href="/tags" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/tags') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-tags text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Tag Management</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tools Section -->
|
||||
|
||||
626
app/Views/tags/index.php
Normal file
626
app/Views/tags/index.php
Normal 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
414
app/Views/tags/view.php
Normal 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';
|
||||
?>
|
||||
90
database/migrations/020_create_tags_system.sql
Normal file
90
database/migrations/020_create_tags_system.sql
Normal 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;
|
||||
@@ -16,6 +16,7 @@ use App\Controllers\InstallerController;
|
||||
use App\Controllers\NotificationController;
|
||||
use App\Controllers\ErrorLogController;
|
||||
use App\Controllers\TwoFactorController;
|
||||
use App\Controllers\TagController;
|
||||
|
||||
$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-add-tags', [DomainController::class, 'bulkAddTags']);
|
||||
$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/bulk-transfer', [DomainController::class, 'bulkTransfer']);
|
||||
$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/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']);
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user