Global Boomarks
This commit is contained in:
@@ -46,6 +46,7 @@ return [
|
||||
'css/vendor-overrides/editorjs.css',
|
||||
'css/components/app-page-copy.css',
|
||||
'css/layout/app-aside-icon-bar.css',
|
||||
'css/components/app-bookmark-form.css',
|
||||
],
|
||||
'login' => [
|
||||
'css/pages/app-login.css',
|
||||
|
||||
@@ -298,20 +298,35 @@ CREATE TABLE IF NOT EXISTS `user_departments` (
|
||||
CONSTRAINT `fk_user_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_saved_filters` (
|
||||
CREATE TABLE IF NOT EXISTS `user_bookmark_groups` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`context` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(120) NOT NULL,
|
||||
`query_json` TEXT NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`icon` VARCHAR(40) NOT NULL DEFAULT 'bi-folder',
|
||||
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_user_saved_filters_uuid` (`uuid`),
|
||||
KEY `idx_user_saved_filters_user_context` (`user_id`, `context`),
|
||||
KEY `idx_user_saved_filters_user_context_created` (`user_id`, `context`, `created`),
|
||||
CONSTRAINT `fk_user_saved_filters_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
KEY `idx_ubg_user_sort` (`user_id`, `sort_order`),
|
||||
CONSTRAINT `fk_ubg_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_bookmarks` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`group_id` INT UNSIGNED NULL,
|
||||
`name` VARCHAR(120) NOT NULL,
|
||||
`url` VARCHAR(500) NOT NULL,
|
||||
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_ub_user_url` (`user_id`, `url`),
|
||||
KEY `idx_ub_user_sort` (`user_id`, `sort_order`),
|
||||
KEY `idx_ub_user_group_sort` (`user_id`, `group_id`, `sort_order`),
|
||||
KEY `idx_ub_group` (`group_id`),
|
||||
CONSTRAINT `fk_ub_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_ub_group` FOREIGN KEY (`group_id`) REFERENCES `user_bookmark_groups` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `tenant_custom_field_definitions` (
|
||||
|
||||
108
db/updates/2026-03-14-user-bookmarks.sql
Normal file
108
db/updates/2026-03-14-user-bookmarks.sql
Normal file
@@ -0,0 +1,108 @@
|
||||
-- Replace user_saved_filters with global bookmark system.
|
||||
-- Hard cut: existing saved filters are removed and not migrated.
|
||||
-- Hard cut: bookmark-level icons are removed; icons live on groups only.
|
||||
-- Idempotent: safe to run multiple times.
|
||||
|
||||
DROP TABLE IF EXISTS `user_saved_filters`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_bookmark_groups` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`icon` VARCHAR(40) NOT NULL DEFAULT 'bi-folder',
|
||||
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_ubg_user_sort` (`user_id`, `sort_order`),
|
||||
CONSTRAINT `fk_ubg_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_bookmarks` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`group_id` INT UNSIGNED NULL,
|
||||
`name` VARCHAR(120) NOT NULL,
|
||||
`url` VARCHAR(500) NOT NULL,
|
||||
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_ub_user_url` (`user_id`, `url`),
|
||||
KEY `idx_ub_user_sort` (`user_id`, `sort_order`),
|
||||
KEY `idx_ub_group` (`group_id`),
|
||||
CONSTRAINT `fk_ub_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_ub_group` FOREIGN KEY (`group_id`) REFERENCES `user_bookmark_groups` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET @ubg_icon_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_bookmark_groups'
|
||||
AND COLUMN_NAME = 'icon'
|
||||
);
|
||||
SET @sql = IF(@ubg_icon_exists = 0,
|
||||
'ALTER TABLE `user_bookmark_groups` ADD COLUMN `icon` VARCHAR(40) NOT NULL DEFAULT ''bi-folder'' AFTER `name`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
UPDATE `user_bookmark_groups`
|
||||
SET `icon` = 'bi-folder'
|
||||
WHERE `icon` IS NULL OR TRIM(`icon`) = '';
|
||||
|
||||
SET @ub_icon_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_bookmarks'
|
||||
AND COLUMN_NAME = 'icon'
|
||||
);
|
||||
SET @sql = IF(@ub_icon_exists > 0,
|
||||
'ALTER TABLE `user_bookmarks` DROP COLUMN `icon`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Keep the newest row for exact duplicate user+url pairs before adding unique key.
|
||||
DELETE ub1
|
||||
FROM user_bookmarks ub1
|
||||
JOIN user_bookmarks ub2
|
||||
ON ub1.user_id = ub2.user_id
|
||||
AND ub1.url = ub2.url
|
||||
AND ub1.id < ub2.id;
|
||||
|
||||
SET @idx_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_bookmarks'
|
||||
AND INDEX_NAME = 'uniq_ub_user_url'
|
||||
);
|
||||
SET @sql = IF(@idx_exists = 0,
|
||||
'ALTER TABLE `user_bookmarks` ADD UNIQUE KEY `uniq_ub_user_url` (`user_id`, `url`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @idx_group_sort_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user_bookmarks'
|
||||
AND INDEX_NAME = 'idx_ub_user_group_sort'
|
||||
);
|
||||
SET @sql = IF(@idx_group_sort_exists = 0,
|
||||
'ALTER TABLE `user_bookmarks` ADD KEY `idx_ub_user_group_sort` (`user_id`, `group_id`, `sort_order`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -1,6 +1,6 @@
|
||||
# Fehlerbehebung
|
||||
|
||||
Letzte Aktualisierung: 2026-03-06
|
||||
Letzte Aktualisierung: 2026-03-14
|
||||
|
||||
## App reagiert nicht wie erwartet
|
||||
|
||||
@@ -58,6 +58,12 @@ Neue Felder/Features fehlen lokal.
|
||||
docker compose restart php
|
||||
```
|
||||
|
||||
### Hinweis (Hard Cut 2026-03-14)
|
||||
|
||||
Das Update `db/updates/2026-03-14-user-bookmarks.sql` entfernt `user_saved_filters` bewusst ohne Migration nach `user_bookmarks`.
|
||||
Zusätzlich wurde der alte Bookmark-Icon-Ansatz pro Eintrag entfernt: Icons gelten nur noch auf Gruppenebene (`user_bookmark_groups.icon`), bestehende Bookmark-Icons werden nicht übernommen.
|
||||
Wenn gespeicherte Filter noch benötigt werden, vor dem Update ein DB-Backup erstellen.
|
||||
|
||||
## i18n Fehler
|
||||
|
||||
### Symptom
|
||||
|
||||
@@ -520,6 +520,7 @@
|
||||
"Department can not be created": "Abteilung kann nicht erstellt werden",
|
||||
"Department can not be updated": "Abteilung kann nicht aktualisiert werden",
|
||||
"Delete this department?": "Diese Abteilung wirklich löschen?",
|
||||
"Delete this bookmark?": "Dieses Lesezeichen wirklich löschen?",
|
||||
"Delete department": "Abteilung löschen",
|
||||
"This will permanently delete this department.": "Diese Abteilung wird dauerhaft gelöscht.",
|
||||
"Assigned departments": "Zugewiesene Abteilungen",
|
||||
@@ -647,15 +648,6 @@
|
||||
"SMTP from address": "Absender-Adresse",
|
||||
"SMTP from name": "Absendername",
|
||||
"Address book": "Adressbuch",
|
||||
"Saved filters": "Gespeicherte Filter",
|
||||
"Save filter": "Filter speichern",
|
||||
"Enter a name for this filter": "Gib einen Namen für diesen Filter ein",
|
||||
"Filter name is required": "Filtername ist erforderlich",
|
||||
"Maximum number of saved filters reached": "Maximale Anzahl gespeicherter Filter erreicht",
|
||||
"Filter saved": "Filter gespeichert",
|
||||
"Delete saved filter": "Gespeicherten Filter löschen",
|
||||
"Filter deleted": "Filter gelöscht",
|
||||
"Filter save failed": "Filter konnte nicht gespeichert werden",
|
||||
"Mobile": "Mobil",
|
||||
"Short dial": "Kurzwahl",
|
||||
"setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)",
|
||||
@@ -1307,5 +1299,45 @@
|
||||
"Session expiring": "Sitzung läuft ab",
|
||||
"Your session will expire in {countdown}. Would you like to continue?": "Ihre Sitzung läuft in {countdown} ab. Möchten Sie fortfahren?",
|
||||
"Extend session": "Sitzung verlängern",
|
||||
"Log out": "Abmelden"
|
||||
"Log out": "Abmelden",
|
||||
"Bookmarks": "Lesezeichen",
|
||||
"Bookmark this page": "Seite als Lesezeichen speichern",
|
||||
"Save bookmark": "Lesezeichen speichern",
|
||||
"Bookmark name": "Lesezeichen-Name",
|
||||
"Delete bookmark": "Lesezeichen löschen",
|
||||
"No bookmarks yet": "Noch keine Lesezeichen",
|
||||
"New group": "Neue Gruppe",
|
||||
"No group": "Keine Gruppe",
|
||||
"Assign to group": "Gruppe zuweisen",
|
||||
"Group name": "Gruppenname",
|
||||
"Delete group": "Gruppe löschen",
|
||||
"Group": "Gruppe",
|
||||
"Icon": "Symbol",
|
||||
"Maximum bookmarks reached": "Maximale Anzahl an Lesezeichen erreicht",
|
||||
"Edit bookmark": "Lesezeichen bearbeiten",
|
||||
"Save changes": "Änderungen speichern",
|
||||
"Bookmark saved": "Lesezeichen gespeichert",
|
||||
"Bookmark updated": "Lesezeichen aktualisiert",
|
||||
"Bookmark deleted": "Lesezeichen gelöscht",
|
||||
"Group created": "Gruppe erstellt",
|
||||
"Group deleted": "Gruppe gelöscht",
|
||||
"Group delete failed": "Gruppe konnte nicht gelöscht werden",
|
||||
"Delete group and keep bookmarks?": "Gruppe löschen und enthaltene Lesezeichen behalten?",
|
||||
"Bookmark action failed": "Lesezeichen-Aktion fehlgeschlagen",
|
||||
"Group action failed": "Gruppen-Aktion fehlgeschlagen",
|
||||
"Reorder saved": "Sortierung gespeichert",
|
||||
"Reorder failed": "Sortierung fehlgeschlagen",
|
||||
"Move group up": "Gruppe nach oben",
|
||||
"Move group down": "Gruppe nach unten",
|
||||
"Move bookmark up": "Lesezeichen nach oben",
|
||||
"Move bookmark down": "Lesezeichen nach unten",
|
||||
"Group actions": "Gruppenaktionen",
|
||||
"Bookmark actions": "Lesezeichenaktionen",
|
||||
"Bookmark name is required": "Lesezeichen-Name ist erforderlich",
|
||||
"Bookmark URL is invalid": "Lesezeichen-URL ist ungültig",
|
||||
"Group not found": "Gruppe nicht gefunden",
|
||||
"Edit group": "Gruppe bearbeiten",
|
||||
"Group updated": "Gruppe aktualisiert",
|
||||
"Group name is required": "Gruppenname ist erforderlich",
|
||||
"Maximum groups reached": "Maximale Anzahl an Gruppen erreicht"
|
||||
}
|
||||
|
||||
@@ -520,6 +520,7 @@
|
||||
"Department can not be created": "Department can not be created",
|
||||
"Department can not be updated": "Department can not be updated",
|
||||
"Delete this department?": "Delete this department?",
|
||||
"Delete this bookmark?": "Delete this bookmark?",
|
||||
"Delete department": "Delete department",
|
||||
"This will permanently delete this department.": "This will permanently delete this department.",
|
||||
"Assigned departments": "Assigned departments",
|
||||
@@ -647,15 +648,6 @@
|
||||
"SMTP from address": "SMTP from address",
|
||||
"SMTP from name": "SMTP from name",
|
||||
"Address book": "Address book",
|
||||
"Saved filters": "Saved filters",
|
||||
"Save filter": "Save filter",
|
||||
"Enter a name for this filter": "Enter a name for this filter",
|
||||
"Filter name is required": "Filter name is required",
|
||||
"Maximum number of saved filters reached": "Maximum number of saved filters reached",
|
||||
"Filter saved": "Filter saved",
|
||||
"Delete saved filter": "Delete saved filter",
|
||||
"Filter deleted": "Filter deleted",
|
||||
"Filter save failed": "Filter save failed",
|
||||
"Mobile": "Mobile",
|
||||
"Short dial": "Short dial",
|
||||
"setting.smtp_host": "SMTP server host (e.g. smtp.example.com)",
|
||||
@@ -1307,5 +1299,45 @@
|
||||
"Session expiring": "Session expiring",
|
||||
"Your session will expire in {countdown}. Would you like to continue?": "Your session will expire in {countdown}. Would you like to continue?",
|
||||
"Extend session": "Extend session",
|
||||
"Log out": "Log out"
|
||||
"Log out": "Log out",
|
||||
"Bookmarks": "Bookmarks",
|
||||
"Bookmark this page": "Bookmark this page",
|
||||
"Save bookmark": "Save bookmark",
|
||||
"Bookmark name": "Bookmark name",
|
||||
"Delete bookmark": "Delete bookmark",
|
||||
"No bookmarks yet": "No bookmarks yet",
|
||||
"New group": "New group",
|
||||
"No group": "No group",
|
||||
"Assign to group": "Assign to group",
|
||||
"Group name": "Group name",
|
||||
"Delete group": "Delete group",
|
||||
"Group": "Group",
|
||||
"Icon": "Icon",
|
||||
"Maximum bookmarks reached": "Maximum bookmarks reached",
|
||||
"Edit bookmark": "Edit bookmark",
|
||||
"Save changes": "Save changes",
|
||||
"Bookmark saved": "Bookmark saved",
|
||||
"Bookmark updated": "Bookmark updated",
|
||||
"Bookmark deleted": "Bookmark deleted",
|
||||
"Group created": "Group created",
|
||||
"Group deleted": "Group deleted",
|
||||
"Group delete failed": "Group delete failed",
|
||||
"Delete group and keep bookmarks?": "Delete group and keep bookmarks?",
|
||||
"Bookmark action failed": "Bookmark action failed",
|
||||
"Group action failed": "Group action failed",
|
||||
"Reorder saved": "Reorder saved",
|
||||
"Reorder failed": "Reorder failed",
|
||||
"Move group up": "Move group up",
|
||||
"Move group down": "Move group down",
|
||||
"Move bookmark up": "Move bookmark up",
|
||||
"Move bookmark down": "Move bookmark down",
|
||||
"Group actions": "Group actions",
|
||||
"Bookmark actions": "Bookmark actions",
|
||||
"Bookmark name is required": "Bookmark name is required",
|
||||
"Bookmark URL is invalid": "Bookmark URL is invalid",
|
||||
"Group not found": "Group not found",
|
||||
"Edit group": "Edit group",
|
||||
"Group updated": "Group updated",
|
||||
"Group name is required": "Group name is required",
|
||||
"Maximum groups reached": "Maximum groups reached"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ use MintyPHP\Service\Tenant\TenantRepositoryFactory;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||
use MintyPHP\Service\Access\AssignableRoleService;
|
||||
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
|
||||
use MintyPHP\Service\User\UserGatewayFactory;
|
||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
@@ -142,6 +143,7 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(TenantScopeService::class)
|
||||
));
|
||||
$container->set(BookmarkServicesFactory::class, static fn (): BookmarkServicesFactory => new BookmarkServicesFactory());
|
||||
$container->set(AuthServicesFactory::class, static fn (AppContainer $c): AuthServicesFactory => new AuthServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
$c->get(AuditServicesFactory::class),
|
||||
@@ -151,7 +153,8 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
||||
$c->get(DatabaseSessionRepository::class),
|
||||
$c->get(SessionStoreInterface::class),
|
||||
$c->get(CookieStoreInterface::class),
|
||||
$c->get(RequestRuntimeInterface::class)
|
||||
$c->get(RequestRuntimeInterface::class),
|
||||
$c->get(BookmarkServicesFactory::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ use MintyPHP\Service\User\UserLifecycleService;
|
||||
use MintyPHP\Service\User\UserPasswordPolicyService;
|
||||
use MintyPHP\Service\User\UserPasswordService;
|
||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||
use MintyPHP\Service\User\UserSavedFilterService;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
@@ -45,7 +46,7 @@ final class UserRegistrar implements ContainerRegistrar
|
||||
$c->get(UserAccessTemplateService::class),
|
||||
$c->get(BrandingLogoService::class)
|
||||
));
|
||||
$container->set(UserSavedFilterService::class, static fn (AppContainer $c): UserSavedFilterService => $c->get(UserServicesFactory::class)->createUserSavedFilterService());
|
||||
$container->set(BookmarkService::class, static fn (AppContainer $c): BookmarkService => $c->get(BookmarkServicesFactory::class)->createBookmarkService());
|
||||
$container->set(UserTenantContextService::class, static fn (AppContainer $c): UserTenantContextService => $c->get(UserServicesFactory::class)->createUserTenantContextService());
|
||||
$container->set(UserLifecycleService::class, static fn (AppContainer $c): UserLifecycleService => $c->get(UserServicesFactory::class)->createUserLifecycleService());
|
||||
$container->set(UserPasswordPolicyService::class, static fn (AppContainer $c): UserPasswordPolicyService => $c->get(UserServicesFactory::class)->createUserPasswordPolicyService());
|
||||
|
||||
209
lib/Repository/User/BookmarkGroupRepository.php
Normal file
209
lib/Repository/User/BookmarkGroupRepository.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class BookmarkGroupRepository implements BookmarkGroupRepositoryInterface
|
||||
{
|
||||
private function unwrapList(mixed $rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_bookmark_groups'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function listByUser(int $userId): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, user_id, name, icon, sort_order, created, modified from user_bookmark_groups where user_id = ? order by sort_order, id',
|
||||
(string) $userId
|
||||
);
|
||||
return $this->unwrapList($rows);
|
||||
}
|
||||
|
||||
public function nextGroupSortOrder(int $userId): int
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$max = DB::selectValue(
|
||||
'select COALESCE(max(sort_order), 0) from user_bookmark_groups where user_id = ?',
|
||||
(string) $userId
|
||||
);
|
||||
|
||||
return max(1, ((int) $max) + 1);
|
||||
}
|
||||
|
||||
public function countByUser(int $userId): int
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$count = DB::selectValue(
|
||||
'select count(*) from user_bookmark_groups where user_id = ?',
|
||||
(string) $userId
|
||||
);
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
|
||||
public function findById(int $id, int $userId): array|false
|
||||
{
|
||||
if ($id <= 0 || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, user_id, name, icon, sort_order, created, modified from user_bookmark_groups where id = ? and user_id = ?',
|
||||
(string) $id,
|
||||
(string) $userId
|
||||
);
|
||||
$list = $this->unwrapList($rows);
|
||||
return $list[0] ?? false;
|
||||
}
|
||||
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
$userId = (int) ($data['user_id'] ?? 0);
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$icon = trim((string) ($data['icon'] ?? 'bi-folder'));
|
||||
$sortOrder = (int) ($data['sort_order'] ?? 0);
|
||||
if ($userId <= 0 || $name === '' || $icon === '') {
|
||||
return false;
|
||||
}
|
||||
return DB::insert(
|
||||
'insert into user_bookmark_groups (user_id, name, icon, sort_order, created) values (?,?,?,?,NOW())',
|
||||
(string) $userId,
|
||||
$name,
|
||||
$icon,
|
||||
(string) $sortOrder
|
||||
);
|
||||
}
|
||||
|
||||
public function update(int $id, int $userId, array $data): bool
|
||||
{
|
||||
if ($id <= 0 || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sets = [];
|
||||
$params = [];
|
||||
|
||||
if (array_key_exists('name', $data)) {
|
||||
$name = trim((string) $data['name']);
|
||||
if ($name === '') {
|
||||
return false;
|
||||
}
|
||||
$sets[] = 'name = ?';
|
||||
$params[] = $name;
|
||||
}
|
||||
if (array_key_exists('icon', $data)) {
|
||||
$icon = trim((string) $data['icon']);
|
||||
if ($icon === '') {
|
||||
return false;
|
||||
}
|
||||
$sets[] = 'icon = ?';
|
||||
$params[] = $icon;
|
||||
}
|
||||
|
||||
if ($sets === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$params[] = (string) $id;
|
||||
$params[] = (string) $userId;
|
||||
|
||||
$affected = DB::update(
|
||||
'update user_bookmark_groups set ' . implode(', ', $sets) . ' where id = ? and user_id = ?',
|
||||
...$params
|
||||
);
|
||||
return $affected !== false;
|
||||
}
|
||||
|
||||
public function delete(int $id, int $userId): bool
|
||||
{
|
||||
if ($id <= 0 || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$deleted = DB::delete(
|
||||
'delete from user_bookmark_groups where id = ? and user_id = ?',
|
||||
(string) $id,
|
||||
(string) $userId
|
||||
);
|
||||
return $deleted !== false && (int) $deleted > 0;
|
||||
}
|
||||
|
||||
public function updateSortOrders(int $userId, array $idOrderPairs): bool
|
||||
{
|
||||
if ($userId <= 0 || $idOrderPairs === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$idList = [];
|
||||
foreach ($idOrderPairs as $pair) {
|
||||
$id = (int) $pair['id'];
|
||||
if ($id <= 0) {
|
||||
return false;
|
||||
}
|
||||
$idList[] = $id;
|
||||
}
|
||||
$idList = array_values(array_unique($idList));
|
||||
|
||||
$existingCount = DB::selectValue(
|
||||
'select count(*) from user_bookmark_groups where user_id = ? and id in (???)',
|
||||
(string) $userId,
|
||||
array_map('strval', $idList)
|
||||
);
|
||||
|
||||
if ((int) $existingCount !== count($idList)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = DB::handle();
|
||||
try {
|
||||
$db->begin_transaction();
|
||||
|
||||
foreach ($idOrderPairs as $pair) {
|
||||
$id = (int) $pair['id'];
|
||||
$sortOrder = (int) $pair['sort_order'];
|
||||
if ($id <= 0 || $sortOrder <= 0) {
|
||||
$db->rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
$updated = DB::update(
|
||||
'update user_bookmark_groups set sort_order = ? where id = ? and user_id = ?',
|
||||
(string) $sortOrder,
|
||||
(string) $id,
|
||||
(string) $userId
|
||||
);
|
||||
|
||||
if ($updated === false) {
|
||||
$db->rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
try {
|
||||
$db->rollback();
|
||||
} catch (\Throwable) {
|
||||
// no-op
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
lib/Repository/User/BookmarkGroupRepositoryInterface.php
Normal file
25
lib/Repository/User/BookmarkGroupRepositoryInterface.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
interface BookmarkGroupRepositoryInterface
|
||||
{
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function listByUser(int $userId): array;
|
||||
|
||||
public function nextGroupSortOrder(int $userId): int;
|
||||
|
||||
public function countByUser(int $userId): int;
|
||||
|
||||
/** @return array<string, mixed>|false */
|
||||
public function findById(int $id, int $userId): array|false;
|
||||
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, int $userId, array $data): bool;
|
||||
|
||||
public function delete(int $id, int $userId): bool;
|
||||
|
||||
/** @param list<array{id: int, sort_order: int}> $idOrderPairs */
|
||||
public function updateSortOrders(int $userId, array $idOrderPairs): bool;
|
||||
}
|
||||
125
lib/Repository/User/BookmarkNavigationRepository.php
Normal file
125
lib/Repository/User/BookmarkNavigationRepository.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class BookmarkNavigationRepository implements BookmarkNavigationRepositoryInterface
|
||||
{
|
||||
public function nextRootSortOrder(int $userId): int
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$max = DB::selectValue(
|
||||
'select GREATEST(
|
||||
COALESCE((select max(sort_order) from user_bookmark_groups where user_id = ?), 0),
|
||||
COALESCE((select max(sort_order) from user_bookmarks where user_id = ? and group_id is NULL), 0)
|
||||
)',
|
||||
(string) $userId,
|
||||
(string) $userId
|
||||
);
|
||||
|
||||
return max(1, ((int) $max) + 1);
|
||||
}
|
||||
|
||||
public function updateRootSortOrders(int $userId, array $rootOrderPairs): bool
|
||||
{
|
||||
if ($userId <= 0 || $rootOrderPairs === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$groupPairs = [];
|
||||
$bookmarkPairs = [];
|
||||
$groupIds = [];
|
||||
$bookmarkIds = [];
|
||||
|
||||
foreach ($rootOrderPairs as $pair) {
|
||||
$kind = trim((string) ($pair['kind'] ?? ''));
|
||||
$id = (int) ($pair['id'] ?? 0);
|
||||
$sortOrder = (int) ($pair['sort_order'] ?? 0);
|
||||
if ($id <= 0 || $sortOrder <= 0 || ($kind !== 'group' && $kind !== 'bookmark')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($kind === 'group') {
|
||||
$groupPairs[] = ['id' => $id, 'sort_order' => $sortOrder];
|
||||
$groupIds[] = $id;
|
||||
continue;
|
||||
}
|
||||
|
||||
$bookmarkPairs[] = ['id' => $id, 'sort_order' => $sortOrder];
|
||||
$bookmarkIds[] = $id;
|
||||
}
|
||||
|
||||
if ($groupPairs === [] && $bookmarkPairs === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($groupIds !== []) {
|
||||
$uniqueGroupIds = array_values(array_unique($groupIds));
|
||||
$groupCount = DB::selectValue(
|
||||
'select count(*) from user_bookmark_groups where user_id = ? and id in (???)',
|
||||
(string) $userId,
|
||||
array_map('strval', $uniqueGroupIds)
|
||||
);
|
||||
if ((int) $groupCount !== count($uniqueGroupIds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bookmarkIds !== []) {
|
||||
$uniqueBookmarkIds = array_values(array_unique($bookmarkIds));
|
||||
$bookmarkCount = DB::selectValue(
|
||||
'select count(*) from user_bookmarks where user_id = ? and group_id is NULL and id in (???)',
|
||||
(string) $userId,
|
||||
array_map('strval', $uniqueBookmarkIds)
|
||||
);
|
||||
if ((int) $bookmarkCount !== count($uniqueBookmarkIds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$db = DB::handle();
|
||||
try {
|
||||
$db->begin_transaction();
|
||||
|
||||
foreach ($groupPairs as $pair) {
|
||||
$updated = DB::update(
|
||||
'update user_bookmark_groups set sort_order = ? where id = ? and user_id = ?',
|
||||
(string) $pair['sort_order'],
|
||||
(string) $pair['id'],
|
||||
(string) $userId
|
||||
);
|
||||
if ($updated === false) {
|
||||
$db->rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($bookmarkPairs as $pair) {
|
||||
$updated = DB::update(
|
||||
'update user_bookmarks set sort_order = ? where id = ? and user_id = ? and group_id is NULL',
|
||||
(string) $pair['sort_order'],
|
||||
(string) $pair['id'],
|
||||
(string) $userId
|
||||
);
|
||||
if ($updated === false) {
|
||||
$db->rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
try {
|
||||
$db->rollback();
|
||||
} catch (\Throwable) {
|
||||
// no-op
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
interface BookmarkNavigationRepositoryInterface
|
||||
{
|
||||
public function nextRootSortOrder(int $userId): int;
|
||||
|
||||
/** @param list<array{kind: 'group'|'bookmark', id: int, sort_order: int}> $rootOrderPairs */
|
||||
public function updateRootSortOrders(int $userId, array $rootOrderPairs): bool;
|
||||
}
|
||||
308
lib/Repository/User/BookmarkRepository.php
Normal file
308
lib/Repository/User/BookmarkRepository.php
Normal file
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class BookmarkRepository implements BookmarkRepositoryInterface
|
||||
{
|
||||
private function unwrapList(mixed $rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_bookmarks'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function listByUser(int $userId): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, user_id, group_id, name, url, sort_order, created, modified from user_bookmarks where user_id = ? order by sort_order, id',
|
||||
(string) $userId
|
||||
);
|
||||
return $this->unwrapList($rows);
|
||||
}
|
||||
|
||||
public function nextBookmarkSortOrder(int $userId, ?int $groupId): int
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($groupId !== null && $groupId > 0) {
|
||||
$max = DB::selectValue(
|
||||
'select COALESCE(max(sort_order), 0) from user_bookmarks where user_id = ? and group_id = ?',
|
||||
(string) $userId,
|
||||
(string) $groupId
|
||||
);
|
||||
return max(1, ((int) $max) + 1);
|
||||
}
|
||||
|
||||
$max = DB::selectValue(
|
||||
'select COALESCE(max(sort_order), 0) from user_bookmarks where user_id = ? and group_id is NULL',
|
||||
(string) $userId
|
||||
);
|
||||
return max(1, ((int) $max) + 1);
|
||||
}
|
||||
|
||||
public function countByUser(int $userId): int
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$count = DB::selectValue(
|
||||
'select count(*) from user_bookmarks where user_id = ?',
|
||||
(string) $userId
|
||||
);
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
|
||||
public function findById(int $id, int $userId): array|false
|
||||
{
|
||||
if ($id <= 0 || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, user_id, group_id, name, url, sort_order, created, modified from user_bookmarks where id = ? and user_id = ?',
|
||||
(string) $id,
|
||||
(string) $userId
|
||||
);
|
||||
$list = $this->unwrapList($rows);
|
||||
return $list[0] ?? false;
|
||||
}
|
||||
|
||||
public function findByUserAndUrl(int $userId, string $url): array|false
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($userId <= 0 || $url === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rows = DB::select(
|
||||
'select id, user_id, group_id, name, url, sort_order, created, modified from user_bookmarks where user_id = ? and url = ? order by id desc limit 1',
|
||||
(string) $userId,
|
||||
$url
|
||||
);
|
||||
$list = $this->unwrapList($rows);
|
||||
return $list[0] ?? false;
|
||||
}
|
||||
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
$userId = (int) ($data['user_id'] ?? 0);
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$url = trim((string) ($data['url'] ?? ''));
|
||||
$groupId = isset($data['group_id']) ? (int) $data['group_id'] : null;
|
||||
$sortOrder = (int) ($data['sort_order'] ?? 0);
|
||||
|
||||
if ($userId <= 0 || $name === '' || $url === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$groupIdParam = ($groupId !== null && $groupId > 0) ? (string) $groupId : null;
|
||||
|
||||
return DB::insert(
|
||||
'insert into user_bookmarks (user_id, group_id, name, url, sort_order, created) values (?,?,?,?,?,NOW())',
|
||||
(string) $userId,
|
||||
$groupIdParam,
|
||||
$name,
|
||||
$url,
|
||||
(string) $sortOrder
|
||||
);
|
||||
}
|
||||
|
||||
public function update(int $id, int $userId, array $data): bool
|
||||
{
|
||||
if ($id <= 0 || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sets = [];
|
||||
$params = [];
|
||||
|
||||
if (array_key_exists('name', $data)) {
|
||||
$name = trim((string) $data['name']);
|
||||
if ($name === '') {
|
||||
return false;
|
||||
}
|
||||
$sets[] = 'name = ?';
|
||||
$params[] = $name;
|
||||
}
|
||||
if (array_key_exists('group_id', $data)) {
|
||||
$sets[] = 'group_id = ?';
|
||||
$groupId = $data['group_id'];
|
||||
$params[] = ($groupId !== null && (int) $groupId > 0) ? (string) (int) $groupId : null;
|
||||
}
|
||||
if (array_key_exists('url', $data)) {
|
||||
$url = trim((string) $data['url']);
|
||||
if ($url === '') {
|
||||
return false;
|
||||
}
|
||||
$sets[] = 'url = ?';
|
||||
$params[] = $url;
|
||||
}
|
||||
if (array_key_exists('sort_order', $data)) {
|
||||
$sortOrder = (int) $data['sort_order'];
|
||||
if ($sortOrder <= 0) {
|
||||
return false;
|
||||
}
|
||||
$sets[] = 'sort_order = ?';
|
||||
$params[] = (string) $sortOrder;
|
||||
}
|
||||
|
||||
if ($sets === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$params[] = (string) $id;
|
||||
$params[] = (string) $userId;
|
||||
|
||||
$affected = DB::update(
|
||||
'update user_bookmarks set ' . implode(', ', $sets) . ' where id = ? and user_id = ?',
|
||||
...$params
|
||||
);
|
||||
return $affected !== false;
|
||||
}
|
||||
|
||||
public function delete(int $id, int $userId): bool
|
||||
{
|
||||
if ($id <= 0 || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
$deleted = DB::delete(
|
||||
'delete from user_bookmarks where id = ? and user_id = ?',
|
||||
(string) $id,
|
||||
(string) $userId
|
||||
);
|
||||
return $deleted !== false && (int) $deleted > 0;
|
||||
}
|
||||
|
||||
public function updateSortOrders(int $userId, array $idOrderPairs): bool
|
||||
{
|
||||
if ($userId <= 0 || $idOrderPairs === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$idList = [];
|
||||
foreach ($idOrderPairs as $pair) {
|
||||
$id = (int) $pair['id'];
|
||||
if ($id <= 0) {
|
||||
return false;
|
||||
}
|
||||
$idList[] = $id;
|
||||
}
|
||||
$idList = array_values(array_unique($idList));
|
||||
|
||||
$existingCount = DB::selectValue(
|
||||
'select count(*) from user_bookmarks where user_id = ? and id in (???)',
|
||||
(string) $userId,
|
||||
array_map('strval', $idList)
|
||||
);
|
||||
|
||||
if ((int) $existingCount !== count($idList)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = DB::handle();
|
||||
try {
|
||||
$db->begin_transaction();
|
||||
|
||||
foreach ($idOrderPairs as $pair) {
|
||||
$id = (int) $pair['id'];
|
||||
$sortOrder = (int) $pair['sort_order'];
|
||||
if ($id <= 0 || $sortOrder <= 0) {
|
||||
$db->rollback();
|
||||
return false;
|
||||
}
|
||||
|
||||
$updated = DB::update(
|
||||
'update user_bookmarks set sort_order = ? where id = ? and user_id = ?',
|
||||
(string) $sortOrder,
|
||||
(string) $id,
|
||||
(string) $userId
|
||||
);
|
||||
if ($updated === false) {
|
||||
$db->rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
try {
|
||||
$db->rollback();
|
||||
} catch (\Throwable) {
|
||||
// no-op
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function clearGroupId(int $groupId, int $userId): bool
|
||||
{
|
||||
if ($groupId <= 0 || $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = DB::handle();
|
||||
try {
|
||||
$db->begin_transaction();
|
||||
|
||||
$baseSort = DB::selectValue(
|
||||
'select GREATEST(
|
||||
COALESCE((select max(sort_order) from user_bookmarks where user_id = ? and group_id is NULL), 0),
|
||||
COALESCE((select max(sort_order) from user_bookmark_groups where user_id = ?), 0)
|
||||
)',
|
||||
(string) $userId,
|
||||
(string) $userId
|
||||
);
|
||||
$nextSort = (int) $baseSort;
|
||||
|
||||
$rows = DB::select(
|
||||
'select id from user_bookmarks where user_id = ? and group_id = ? order by sort_order, id',
|
||||
(string) $userId,
|
||||
(string) $groupId
|
||||
);
|
||||
$bookmarks = $this->unwrapList($rows);
|
||||
|
||||
foreach ($bookmarks as $bookmark) {
|
||||
$bookmarkId = (int) ($bookmark['id'] ?? 0);
|
||||
if ($bookmarkId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$nextSort++;
|
||||
$updated = DB::update(
|
||||
'update user_bookmarks set group_id = NULL, sort_order = ? where id = ? and user_id = ?',
|
||||
(string) $nextSort,
|
||||
(string) $bookmarkId,
|
||||
(string) $userId
|
||||
);
|
||||
if ($updated === false) {
|
||||
$db->rollback();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$db->commit();
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
try {
|
||||
$db->rollback();
|
||||
} catch (\Throwable) {
|
||||
// no-op
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
lib/Repository/User/BookmarkRepositoryInterface.php
Normal file
30
lib/Repository/User/BookmarkRepositoryInterface.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
interface BookmarkRepositoryInterface
|
||||
{
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function listByUser(int $userId): array;
|
||||
|
||||
public function nextBookmarkSortOrder(int $userId, ?int $groupId): int;
|
||||
|
||||
public function countByUser(int $userId): int;
|
||||
|
||||
/** @return array<string, mixed>|false */
|
||||
public function findById(int $id, int $userId): array|false;
|
||||
|
||||
/** @return array<string, mixed>|false */
|
||||
public function findByUserAndUrl(int $userId, string $url): array|false;
|
||||
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, int $userId, array $data): bool;
|
||||
|
||||
public function delete(int $id, int $userId): bool;
|
||||
|
||||
/** @param list<array{id: int, sort_order: int}> $idOrderPairs */
|
||||
public function updateSortOrders(int $userId, array $idOrderPairs): bool;
|
||||
|
||||
public function clearGroupId(int $groupId, int $userId): bool;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Persists and retrieves user-defined search filter presets per list context. */
|
||||
class UserSavedFilterRepository implements UserSavedFilterRepositoryInterface
|
||||
{
|
||||
private function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_saved_filters'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function listByUserAndContext(int $userId, string $context): array
|
||||
{
|
||||
if ($userId <= 0 || $context === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, uuid, user_id, context, name, query_json, created, modified from user_saved_filters where user_id = ? and context = ? order by created desc, id desc',
|
||||
(string) $userId,
|
||||
$context
|
||||
);
|
||||
return $this->unwrapList($rows);
|
||||
}
|
||||
|
||||
public function countByUserAndContext(int $userId, string $context): int
|
||||
{
|
||||
if ($userId <= 0 || $context === '') {
|
||||
return 0;
|
||||
}
|
||||
$count = DB::selectValue(
|
||||
'select count(*) from user_saved_filters where user_id = ? and context = ?',
|
||||
(string) $userId,
|
||||
$context
|
||||
);
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
$userId = (int) ($data['user_id'] ?? 0);
|
||||
$context = trim((string) ($data['context'] ?? ''));
|
||||
$name = trim((string) ($data['name'] ?? ''));
|
||||
$queryJson = (string) ($data['query_json'] ?? '');
|
||||
if ($userId <= 0 || $context === '' || $name === '' || $queryJson === '') {
|
||||
return false;
|
||||
}
|
||||
return DB::insert(
|
||||
'insert into user_saved_filters (uuid, user_id, context, name, query_json, created) values (?,?,?,?,?,NOW())',
|
||||
$data['uuid'] ?? RepoQuery::uuidV4(),
|
||||
(string) $userId,
|
||||
$context,
|
||||
$name,
|
||||
$queryJson
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteByUuidForUserAndContext(string $uuid, int $userId, string $context): bool
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
$context = trim($context);
|
||||
if ($uuid === '' || $userId <= 0 || $context === '') {
|
||||
return false;
|
||||
}
|
||||
$deleted = DB::delete(
|
||||
'delete from user_saved_filters where uuid = ? and user_id = ? and context = ?',
|
||||
$uuid,
|
||||
(string) $userId,
|
||||
$context
|
||||
);
|
||||
return $deleted !== false && (int) $deleted > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
/** Contract for per-user, per-context persistence of saved search filters. */
|
||||
interface UserSavedFilterRepositoryInterface
|
||||
{
|
||||
public function listByUserAndContext(int $userId, string $context): array;
|
||||
|
||||
public function countByUserAndContext(int $userId, string $context): int;
|
||||
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function deleteByUuidForUserAndContext(string $uuid, int $userId, string $context): bool;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Service\Audit\AuditServicesFactory;
|
||||
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
|
||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
@@ -32,7 +33,8 @@ class AuthServicesFactory
|
||||
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||
private readonly SessionStoreInterface $sessionStore,
|
||||
private readonly CookieStoreInterface $cookieStore,
|
||||
private readonly RequestRuntimeInterface $requestRuntime
|
||||
private readonly RequestRuntimeInterface $requestRuntime,
|
||||
private readonly BookmarkServicesFactory $bookmarkServicesFactory
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -150,7 +152,7 @@ class AuthServicesFactory
|
||||
{
|
||||
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(
|
||||
$this->userServicesFactory->createUserTenantContextService(),
|
||||
$this->userServicesFactory->createUserSavedFilterService(),
|
||||
$this->bookmarkServicesFactory->createBookmarkService(),
|
||||
$this->sessionStore
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Service\User\UserSavedFilterService;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
class AuthSessionTenantContextService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserTenantContextService $userTenantContextService,
|
||||
private readonly UserSavedFilterService $savedFilterService,
|
||||
private readonly BookmarkService $bookmarkService,
|
||||
private readonly SessionStoreInterface $sessionStore
|
||||
) {
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class AuthSessionTenantContextService
|
||||
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
|
||||
$this->sessionStore->set('available_tenants', $availableTenants);
|
||||
$this->sessionStore->set('available_departments_by_tenant', $this->userTenantContextService->getAvailableDepartmentsByTenant($userId));
|
||||
$this->sessionStore->set('address_book_saved_filters', $this->savedFilterService->listForAddressBook($userId));
|
||||
$this->sessionStore->set('user_bookmarks', $this->bookmarkService->listGroupedForUser($userId));
|
||||
|
||||
if (!$availableTenants) {
|
||||
$this->sessionStore->set('no_active_tenant', true);
|
||||
|
||||
385
lib/Service/Bookmark/BookmarkService.php
Normal file
385
lib/Service/Bookmark/BookmarkService.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Bookmark;
|
||||
|
||||
use MintyPHP\Repository\User\BookmarkGroupRepositoryInterface;
|
||||
use MintyPHP\Repository\User\BookmarkNavigationRepositoryInterface;
|
||||
use MintyPHP\Repository\User\BookmarkRepositoryInterface;
|
||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
||||
|
||||
class BookmarkService
|
||||
{
|
||||
private const MAX_BOOKMARKS = 50;
|
||||
private const MAX_GROUPS = 10;
|
||||
private const NAME_MAX = 120;
|
||||
private const GROUP_NAME_MAX = 100;
|
||||
private const URL_MAX = 500;
|
||||
|
||||
private const ALLOWED_GROUP_ICONS = [
|
||||
'bi-house', 'bi-people', 'bi-person', 'bi-folder', 'bi-file-text',
|
||||
'bi-gear', 'bi-shield-lock', 'bi-bar-chart', 'bi-envelope', 'bi-calendar',
|
||||
'bi-clock', 'bi-star', 'bi-heart', 'bi-flag', 'bi-bookmark',
|
||||
'bi-pin-map', 'bi-lightning', 'bi-database', 'bi-globe', 'bi-code-slash',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly BookmarkRepositoryInterface $bookmarkRepository,
|
||||
private readonly BookmarkGroupRepositoryInterface $groupRepository,
|
||||
private readonly BookmarkNavigationRepositoryInterface $navigationRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function allowedGroupIcons(): array
|
||||
{
|
||||
return self::ALLOWED_GROUP_ICONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{groups: list<array<string, mixed>>, ungrouped: list<array<string, mixed>>}
|
||||
*/
|
||||
public function listGroupedForUser(int $userId): array
|
||||
{
|
||||
$groups = $this->groupRepository->listByUser($userId);
|
||||
$bookmarks = $this->bookmarkRepository->listByUser($userId);
|
||||
|
||||
$grouped = [];
|
||||
foreach ($groups as $group) {
|
||||
$gid = (int) ($group['id'] ?? 0);
|
||||
$grouped[$gid] = [
|
||||
'id' => $gid,
|
||||
'name' => (string) ($group['name'] ?? ''),
|
||||
'icon' => (string) ($group['icon'] ?? 'bi-folder'),
|
||||
'sort_order' => (int) ($group['sort_order'] ?? 0),
|
||||
'bookmarks' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$ungrouped = [];
|
||||
foreach ($bookmarks as $bm) {
|
||||
$item = [
|
||||
'id' => (int) ($bm['id'] ?? 0),
|
||||
'name' => (string) ($bm['name'] ?? ''),
|
||||
'url' => BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? '')),
|
||||
'group_id' => isset($bm['group_id']) ? (int) $bm['group_id'] : null,
|
||||
'sort_order' => (int) ($bm['sort_order'] ?? 0),
|
||||
];
|
||||
|
||||
$gid = $item['group_id'];
|
||||
if ($gid !== null && $gid > 0 && isset($grouped[$gid])) {
|
||||
$grouped[$gid]['bookmarks'][] = $item;
|
||||
} else {
|
||||
$ungrouped[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'groups' => array_values($grouped),
|
||||
'ungrouped' => $ungrouped,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, mode?: 'created'|'updated', error?: string, bookmark?: array<string, mixed>}
|
||||
*/
|
||||
public function saveBookmark(int $userId, string $name, string $url, ?int $groupId): array
|
||||
{
|
||||
$name = $this->truncate(trim($name), self::NAME_MAX);
|
||||
if ($name === '') {
|
||||
return ['ok' => false, 'error' => 'name_required'];
|
||||
}
|
||||
|
||||
$url = $this->sanitizeUrl($url);
|
||||
if ($url === '') {
|
||||
return ['ok' => false, 'error' => 'url_required'];
|
||||
}
|
||||
|
||||
$existing = $this->findExistingByCanonicalUrl($userId, $url);
|
||||
|
||||
if ($groupId !== null && $groupId > 0) {
|
||||
$group = $this->groupRepository->findById($groupId, $userId);
|
||||
if (!$group) {
|
||||
return ['ok' => false, 'error' => 'group_not_found'];
|
||||
}
|
||||
} else {
|
||||
$groupId = null;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$bookmarkId = (int) ($existing['id'] ?? 0);
|
||||
if ($bookmarkId <= 0) {
|
||||
return ['ok' => false, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$existingGroupId = isset($existing['group_id']) && (int) $existing['group_id'] > 0
|
||||
? (int) $existing['group_id']
|
||||
: null;
|
||||
|
||||
$update = [
|
||||
'name' => $name,
|
||||
'group_id' => $groupId,
|
||||
];
|
||||
|
||||
if ($existingGroupId !== $groupId) {
|
||||
$update['sort_order'] = $groupId === null
|
||||
? $this->navigationRepository->nextRootSortOrder($userId)
|
||||
: $this->bookmarkRepository->nextBookmarkSortOrder($userId, $groupId);
|
||||
}
|
||||
|
||||
$existingUrl = trim((string) ($existing['url'] ?? ''));
|
||||
if ($existingUrl !== $url) {
|
||||
$update['url'] = $url;
|
||||
}
|
||||
|
||||
if (!$this->bookmarkRepository->update($bookmarkId, $userId, $update)) {
|
||||
return ['ok' => false, 'error' => 'update_failed'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'mode' => 'updated',
|
||||
'bookmark' => [
|
||||
'id' => $bookmarkId,
|
||||
'name' => $name,
|
||||
'url' => $url,
|
||||
'group_id' => $groupId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->bookmarkRepository->countByUser($userId) >= self::MAX_BOOKMARKS) {
|
||||
return ['ok' => false, 'error' => 'max_reached'];
|
||||
}
|
||||
|
||||
$id = $this->bookmarkRepository->create([
|
||||
'user_id' => $userId,
|
||||
'group_id' => $groupId,
|
||||
'name' => $name,
|
||||
'url' => $url,
|
||||
'sort_order' => $groupId === null
|
||||
? $this->navigationRepository->nextRootSortOrder($userId)
|
||||
: $this->bookmarkRepository->nextBookmarkSortOrder($userId, $groupId),
|
||||
]);
|
||||
|
||||
if ($id === false) {
|
||||
$raceExisting = $this->bookmarkRepository->findByUserAndUrl($userId, $url);
|
||||
if ($raceExisting) {
|
||||
$bookmarkId = (int) ($raceExisting['id'] ?? 0);
|
||||
$raceGroupId = isset($raceExisting['group_id']) && (int) $raceExisting['group_id'] > 0
|
||||
? (int) $raceExisting['group_id']
|
||||
: null;
|
||||
$raceUpdate = [
|
||||
'name' => $name,
|
||||
'group_id' => $groupId,
|
||||
];
|
||||
if ($raceGroupId !== $groupId) {
|
||||
$raceUpdate['sort_order'] = $groupId === null
|
||||
? $this->navigationRepository->nextRootSortOrder($userId)
|
||||
: $this->bookmarkRepository->nextBookmarkSortOrder($userId, $groupId);
|
||||
}
|
||||
if ($bookmarkId > 0 && $this->bookmarkRepository->update($bookmarkId, $userId, $raceUpdate)) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'mode' => 'updated',
|
||||
'bookmark' => [
|
||||
'id' => $bookmarkId,
|
||||
'name' => $name,
|
||||
'url' => $url,
|
||||
'group_id' => $groupId,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => false, 'error' => 'create_failed'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'mode' => 'created',
|
||||
'bookmark' => [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'url' => $url,
|
||||
'group_id' => $groupId,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array{ok: bool, error?: string}
|
||||
*/
|
||||
public function updateBookmark(int $userId, int $bookmarkId, array $data): array
|
||||
{
|
||||
$existing = $this->bookmarkRepository->findById($bookmarkId, $userId);
|
||||
if (!$existing) {
|
||||
return ['ok' => false, 'error' => 'not_found'];
|
||||
}
|
||||
|
||||
$update = [];
|
||||
|
||||
if (array_key_exists('name', $data)) {
|
||||
$name = $this->truncate(trim((string) $data['name']), self::NAME_MAX);
|
||||
if ($name === '') {
|
||||
return ['ok' => false, 'error' => 'name_required'];
|
||||
}
|
||||
$update['name'] = $name;
|
||||
}
|
||||
|
||||
if (array_key_exists('group_id', $data)) {
|
||||
$groupId = $data['group_id'];
|
||||
$targetGroupId = null;
|
||||
if ($groupId !== null && $groupId !== '' && (int) $groupId > 0) {
|
||||
$group = $this->groupRepository->findById((int) $groupId, $userId);
|
||||
if (!$group) {
|
||||
return ['ok' => false, 'error' => 'group_not_found'];
|
||||
}
|
||||
$targetGroupId = (int) $groupId;
|
||||
$update['group_id'] = $targetGroupId;
|
||||
} else {
|
||||
$update['group_id'] = null;
|
||||
}
|
||||
|
||||
$existingGroupId = isset($existing['group_id']) && (int) $existing['group_id'] > 0
|
||||
? (int) $existing['group_id']
|
||||
: null;
|
||||
if ($existingGroupId !== $targetGroupId) {
|
||||
$update['sort_order'] = $targetGroupId === null
|
||||
? $this->navigationRepository->nextRootSortOrder($userId)
|
||||
: $this->bookmarkRepository->nextBookmarkSortOrder($userId, $targetGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
if ($update === []) {
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
if (!$this->bookmarkRepository->update($bookmarkId, $userId, $update)) {
|
||||
return ['ok' => false, 'error' => 'update_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public function deleteBookmark(int $userId, int $bookmarkId): bool
|
||||
{
|
||||
return $this->bookmarkRepository->delete($bookmarkId, $userId);
|
||||
}
|
||||
|
||||
/** @param list<array{id: int, sort_order: int}> $idOrderPairs */
|
||||
public function reorderBookmarks(int $userId, array $idOrderPairs): bool
|
||||
{
|
||||
return $this->bookmarkRepository->updateSortOrders($userId, $idOrderPairs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, mode?: 'created'|'updated', error?: string, group?: array<string, mixed>}
|
||||
*/
|
||||
public function saveGroup(int $userId, string $name, string $icon, ?int $groupId = null): array
|
||||
{
|
||||
$name = $this->truncate(trim($name), self::GROUP_NAME_MAX);
|
||||
if ($name === '') {
|
||||
return ['ok' => false, 'error' => 'name_required'];
|
||||
}
|
||||
$icon = $this->validateGroupIcon($icon);
|
||||
|
||||
if ($groupId !== null && $groupId > 0) {
|
||||
$existing = $this->groupRepository->findById($groupId, $userId);
|
||||
if (!$existing) {
|
||||
return ['ok' => false, 'error' => 'not_found'];
|
||||
}
|
||||
if (!$this->groupRepository->update($groupId, $userId, ['name' => $name, 'icon' => $icon])) {
|
||||
return ['ok' => false, 'error' => 'update_failed'];
|
||||
}
|
||||
return ['ok' => true, 'mode' => 'updated', 'group' => ['id' => $groupId, 'name' => $name, 'icon' => $icon]];
|
||||
}
|
||||
|
||||
if ($this->groupRepository->countByUser($userId) >= self::MAX_GROUPS) {
|
||||
return ['ok' => false, 'error' => 'max_reached'];
|
||||
}
|
||||
|
||||
$id = $this->groupRepository->create([
|
||||
'user_id' => $userId,
|
||||
'name' => $name,
|
||||
'icon' => $icon,
|
||||
'sort_order' => $this->navigationRepository->nextRootSortOrder($userId),
|
||||
]);
|
||||
|
||||
if ($id === false) {
|
||||
return ['ok' => false, 'error' => 'create_failed'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'mode' => 'created', 'group' => ['id' => $id, 'name' => $name, 'icon' => $icon]];
|
||||
}
|
||||
|
||||
public function deleteGroup(int $userId, int $groupId): bool
|
||||
{
|
||||
if (!$this->bookmarkRepository->clearGroupId($groupId, $userId)) {
|
||||
return false;
|
||||
}
|
||||
return $this->groupRepository->delete($groupId, $userId);
|
||||
}
|
||||
|
||||
/** @param list<array{id: int, sort_order: int}> $idOrderPairs */
|
||||
public function reorderGroups(int $userId, array $idOrderPairs): bool
|
||||
{
|
||||
return $this->groupRepository->updateSortOrders($userId, $idOrderPairs);
|
||||
}
|
||||
|
||||
/** @param list<array{kind: 'group'|'bookmark', id: int, sort_order: int}> $rootOrderPairs */
|
||||
public function reorderRoot(int $userId, array $rootOrderPairs): bool
|
||||
{
|
||||
return $this->navigationRepository->updateRootSortOrders($userId, $rootOrderPairs);
|
||||
}
|
||||
|
||||
private function sanitizeUrl(string $url): string
|
||||
{
|
||||
$url = BookmarkUrlNormalizer::canonicalizeRelative($url);
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (mb_strlen($url) > self::URL_MAX) {
|
||||
$url = mb_substr($url, 0, self::URL_MAX);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
private function validateGroupIcon(string $icon): string
|
||||
{
|
||||
$icon = trim($icon);
|
||||
if (in_array($icon, self::ALLOWED_GROUP_ICONS, true)) {
|
||||
return $icon;
|
||||
}
|
||||
return 'bi-folder';
|
||||
}
|
||||
|
||||
private function truncate(string $value, int $maxLength): string
|
||||
{
|
||||
if (mb_strlen($value) > $maxLength) {
|
||||
return mb_substr($value, 0, $maxLength);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|false */
|
||||
private function findExistingByCanonicalUrl(int $userId, string $canonicalUrl): array|false
|
||||
{
|
||||
$existing = $this->bookmarkRepository->findByUserAndUrl($userId, $canonicalUrl);
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$rows = $this->bookmarkRepository->listByUser($userId);
|
||||
foreach ($rows as $row) {
|
||||
$rowUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($row['url'] ?? ''));
|
||||
if ($rowUrl === $canonicalUrl) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
39
lib/Service/Bookmark/BookmarkServicesFactory.php
Normal file
39
lib/Service/Bookmark/BookmarkServicesFactory.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Bookmark;
|
||||
|
||||
use MintyPHP\Repository\User\BookmarkGroupRepository;
|
||||
use MintyPHP\Repository\User\BookmarkNavigationRepository;
|
||||
use MintyPHP\Repository\User\BookmarkRepository;
|
||||
|
||||
class BookmarkServicesFactory
|
||||
{
|
||||
private ?BookmarkService $bookmarkService = null;
|
||||
private ?BookmarkRepository $bookmarkRepository = null;
|
||||
private ?BookmarkGroupRepository $groupRepository = null;
|
||||
private ?BookmarkNavigationRepository $navigationRepository = null;
|
||||
|
||||
public function createBookmarkService(): BookmarkService
|
||||
{
|
||||
return $this->bookmarkService ??= new BookmarkService(
|
||||
$this->createBookmarkRepository(),
|
||||
$this->createBookmarkGroupRepository(),
|
||||
$this->createBookmarkNavigationRepository()
|
||||
);
|
||||
}
|
||||
|
||||
public function createBookmarkRepository(): BookmarkRepository
|
||||
{
|
||||
return $this->bookmarkRepository ??= new BookmarkRepository();
|
||||
}
|
||||
|
||||
public function createBookmarkGroupRepository(): BookmarkGroupRepository
|
||||
{
|
||||
return $this->groupRepository ??= new BookmarkGroupRepository();
|
||||
}
|
||||
|
||||
public function createBookmarkNavigationRepository(): BookmarkNavigationRepository
|
||||
{
|
||||
return $this->navigationRepository ??= new BookmarkNavigationRepository();
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,6 @@ use MintyPHP\Repository\User\UserListQueryRepository;
|
||||
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepository;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserSavedFilterRepository;
|
||||
use MintyPHP\Repository\User\UserSavedFilterRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepository;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
|
||||
@@ -22,7 +20,6 @@ class UserRepositoryFactory
|
||||
private ?UserReadRepository $userReadRepository = null;
|
||||
private ?UserWriteRepository $userWriteRepository = null;
|
||||
private ?UserListQueryRepository $userListQueryRepository = null;
|
||||
private ?UserSavedFilterRepository $userSavedFilterRepository = null;
|
||||
private ?UserTenantRepository $userTenantRepository = null;
|
||||
private ?UserRoleRepository $userRoleRepository = null;
|
||||
private ?UserDepartmentRepository $userDepartmentRepository = null;
|
||||
@@ -42,11 +39,6 @@ class UserRepositoryFactory
|
||||
return $this->userListQueryRepository ??= new UserListQueryRepository();
|
||||
}
|
||||
|
||||
public function createUserSavedFilterRepository(): UserSavedFilterRepositoryInterface
|
||||
{
|
||||
return $this->userSavedFilterRepository ??= new UserSavedFilterRepository();
|
||||
}
|
||||
|
||||
public function createUserTenantRepository(): UserTenantRepositoryInterface
|
||||
{
|
||||
return $this->userTenantRepository ??= new UserTenantRepository();
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\User;
|
||||
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Repository\User\UserSavedFilterRepositoryInterface;
|
||||
|
||||
class UserSavedFilterService
|
||||
{
|
||||
public const CONTEXT_ADDRESS_BOOK = 'address_book';
|
||||
private const NAME_MAX_LENGTH = 120;
|
||||
private const SEARCH_MAX_LENGTH = 200;
|
||||
private const MAX_PER_USER_CONTEXT = 20;
|
||||
|
||||
public function __construct(private readonly UserSavedFilterRepositoryInterface $userSavedFilterRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function listForAddressBook(int $userId): array
|
||||
{
|
||||
return $this->listByContext($userId, self::CONTEXT_ADDRESS_BOOK);
|
||||
}
|
||||
|
||||
public function saveAddressBookFilter(int $userId, string $name, array $rawQuery): array
|
||||
{
|
||||
return $this->saveByContext($userId, self::CONTEXT_ADDRESS_BOOK, $name, $rawQuery);
|
||||
}
|
||||
|
||||
public function deleteAddressBookFilter(int $userId, string $uuid): bool
|
||||
{
|
||||
return $this->deleteByContext($userId, self::CONTEXT_ADDRESS_BOOK, $uuid);
|
||||
}
|
||||
|
||||
public function listByContext(int $userId, string $context): array
|
||||
{
|
||||
if ($userId <= 0 || $context === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = $this->userSavedFilterRepository->listByUserAndContext($userId, $context);
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$name = trim((string) ($row['name'] ?? ''));
|
||||
$uuid = trim((string) ($row['uuid'] ?? ''));
|
||||
if ($name === '' || $uuid === '') {
|
||||
continue;
|
||||
}
|
||||
$query = $this->normalizeQuery($this->decodeQuery((string) ($row['query_json'] ?? '')));
|
||||
$list[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'uuid' => $uuid,
|
||||
'name' => $name,
|
||||
'query' => $query,
|
||||
'created' => (string) ($row['created'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function saveByContext(int $userId, string $context, string $name, array $rawQuery): array
|
||||
{
|
||||
$name = $this->truncate(trim($name), self::NAME_MAX_LENGTH);
|
||||
if ($userId <= 0 || $context === '') {
|
||||
return ['ok' => false, 'error' => 'invalid_user_or_context'];
|
||||
}
|
||||
if ($name === '') {
|
||||
return ['ok' => false, 'error' => 'name_required'];
|
||||
}
|
||||
$count = $this->userSavedFilterRepository->countByUserAndContext($userId, $context);
|
||||
if ($count >= self::MAX_PER_USER_CONTEXT) {
|
||||
return ['ok' => false, 'error' => 'max_reached'];
|
||||
}
|
||||
$query = $this->normalizeQuery($rawQuery);
|
||||
$queryJson = json_encode($query, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($queryJson)) {
|
||||
$queryJson = '{}';
|
||||
}
|
||||
$id = $this->userSavedFilterRepository->create([
|
||||
'user_id' => $userId,
|
||||
'context' => $context,
|
||||
'name' => $name,
|
||||
'query_json' => $queryJson,
|
||||
]);
|
||||
if (!$id) {
|
||||
return ['ok' => false, 'error' => 'create_failed'];
|
||||
}
|
||||
return ['ok' => true, 'id' => (int) $id, 'name' => $name, 'query' => $query];
|
||||
}
|
||||
|
||||
public function deleteByContext(int $userId, string $context, string $uuid): bool
|
||||
{
|
||||
if ($userId <= 0 || $context === '' || trim($uuid) === '') {
|
||||
return false;
|
||||
}
|
||||
return $this->userSavedFilterRepository->deleteByUuidForUserAndContext($uuid, $userId, $context);
|
||||
}
|
||||
|
||||
private function decodeQuery(string $json): array
|
||||
{
|
||||
$decoded = json_decode($json, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function normalizeQuery(array $rawQuery): array
|
||||
{
|
||||
$search = $this->truncate(trim((string) ($rawQuery['search'] ?? '')), self::SEARCH_MAX_LENGTH);
|
||||
$tenants = $this->normalizeUuidList($rawQuery['tenants'] ?? []);
|
||||
$departments = RepoQuery::normalizeIdList($rawQuery['departments'] ?? []);
|
||||
$roles = RepoQuery::normalizeIdList($rawQuery['roles'] ?? []);
|
||||
$custom = $this->normalizeCustomFieldQuery($rawQuery);
|
||||
|
||||
$query = [];
|
||||
if ($search !== '') {
|
||||
$query['search'] = $search;
|
||||
}
|
||||
if ($tenants) {
|
||||
$query['tenants'] = $tenants;
|
||||
}
|
||||
if ($departments) {
|
||||
$query['departments'] = $departments;
|
||||
}
|
||||
if ($roles) {
|
||||
$query['roles'] = $roles;
|
||||
}
|
||||
foreach ($custom as $key => $value) {
|
||||
$query[$key] = $value;
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldQuery(array $rawQuery): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($rawQuery as $rawKey => $rawValue) {
|
||||
$key = strtolower(trim((string) $rawKey));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) {
|
||||
$value = trim((string) $rawValue);
|
||||
if ($value !== '' && preg_match('/^[a-z0-9_-]{1,64}$/i', $value)) {
|
||||
$normalized[$key] = $value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) {
|
||||
$ids = RepoQuery::normalizeIdList($rawValue);
|
||||
if ($ids) {
|
||||
$normalized[$key] = $ids;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) {
|
||||
$value = trim((string) $rawValue);
|
||||
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
|
||||
if ($dt && $dt->format('Y-m-d') === $value) {
|
||||
$normalized[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
ksort($normalized, SORT_STRING);
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeUuidList($value): array
|
||||
{
|
||||
$items = $this->normalizeStringList($value);
|
||||
$list = [];
|
||||
foreach ($items as $item) {
|
||||
$item = strtolower($item);
|
||||
if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $item)) {
|
||||
$list[] = $item;
|
||||
}
|
||||
}
|
||||
$list = array_values(array_unique($list));
|
||||
sort($list, SORT_STRING);
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function normalizeStringList($value): array
|
||||
{
|
||||
$raw = is_array($value) ? $value : [$value];
|
||||
$flat = [];
|
||||
$collect = static function ($item) use (&$collect, &$flat): void {
|
||||
if (is_array($item)) {
|
||||
foreach ($item as $nested) {
|
||||
$collect($nested);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$text = trim((string) $item);
|
||||
if ($text === '') {
|
||||
return;
|
||||
}
|
||||
if (str_contains($text, ',')) {
|
||||
foreach (explode(',', $text) as $part) {
|
||||
$part = trim($part);
|
||||
if ($part !== '') {
|
||||
$flat[] = $part;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
$flat[] = $text;
|
||||
};
|
||||
foreach ($raw as $item) {
|
||||
$collect($item);
|
||||
}
|
||||
return array_values(array_unique($flat));
|
||||
}
|
||||
|
||||
private function truncate(string $value, int $maxLength): string
|
||||
{
|
||||
if ($maxLength <= 0 || $value === '') {
|
||||
return $value;
|
||||
}
|
||||
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
|
||||
if (mb_strlen($value) > $maxLength) {
|
||||
return mb_substr($value, 0, $maxLength);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
if (strlen($value) > $maxLength) {
|
||||
return substr($value, 0, $maxLength);
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserSavedFilterRepositoryInterface;
|
||||
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
||||
use MintyPHP\Service\Access\AssignableRoleService;
|
||||
use MintyPHP\Service\Audit\AuditServicesFactory;
|
||||
@@ -19,7 +18,6 @@ class UserServicesFactory
|
||||
private ?UserPasswordPolicyService $userPasswordPolicyService = null;
|
||||
private ?UserPasswordService $userPasswordService = null;
|
||||
private ?UserAvatarService $userAvatarService = null;
|
||||
private ?UserSavedFilterService $userSavedFilterService = null;
|
||||
private ?UserAssignmentService $userAssignmentService = null;
|
||||
private ?UserTenantContextService $userTenantContextService = null;
|
||||
private ?UserAccountService $userAccountService = null;
|
||||
@@ -93,13 +91,6 @@ class UserServicesFactory
|
||||
return $this->userAvatarService ??= new UserAvatarService();
|
||||
}
|
||||
|
||||
public function createUserSavedFilterService(): UserSavedFilterService
|
||||
{
|
||||
return $this->userSavedFilterService ??= new UserSavedFilterService(
|
||||
$this->createUserSavedFilterRepository()
|
||||
);
|
||||
}
|
||||
|
||||
public function createUserReadRepository(): UserReadRepositoryInterface
|
||||
{
|
||||
return $this->userRepositoryFactory->createUserReadRepository();
|
||||
@@ -115,11 +106,6 @@ class UserServicesFactory
|
||||
return $this->userRepositoryFactory->createUserListQueryRepository();
|
||||
}
|
||||
|
||||
public function createUserSavedFilterRepository(): UserSavedFilterRepositoryInterface
|
||||
{
|
||||
return $this->userRepositoryFactory->createUserSavedFilterRepository();
|
||||
}
|
||||
|
||||
public function createUserTenantRepository(): UserTenantRepositoryInterface
|
||||
{
|
||||
return $this->userRepositoryFactory->createUserTenantRepository();
|
||||
|
||||
146
lib/Support/BookmarkUrlNormalizer.php
Normal file
146
lib/Support/BookmarkUrlNormalizer.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
final class BookmarkUrlNormalizer
|
||||
{
|
||||
public static function canonicalizeRelative(string $url): string
|
||||
{
|
||||
$url = trim($url);
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$hashPos = strpos($url, '#');
|
||||
if ($hashPos !== false) {
|
||||
$url = substr($url, 0, $hashPos);
|
||||
}
|
||||
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $url) || str_starts_with($url, '//')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = self::normalizePath((string) ($parts['path'] ?? ''));
|
||||
$query = self::normalizeQuery((string) ($parts['query'] ?? ''));
|
||||
|
||||
if ($path === '' && $query === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($query === '') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return $path !== '' ? $path . '?' . $query : '?' . $query;
|
||||
}
|
||||
|
||||
public static function canonicalizeRequestUri(string $requestUri, string $localeBase): string
|
||||
{
|
||||
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?? '');
|
||||
$requestQuery = (string) (parse_url($requestUri, PHP_URL_QUERY) ?? '');
|
||||
|
||||
$basePath = (string) (parse_url($localeBase, PHP_URL_PATH) ?? '');
|
||||
$basePath = '/' . trim($basePath, '/');
|
||||
|
||||
if ($basePath !== '/' && ($requestPath === $basePath || str_starts_with($requestPath, $basePath . '/'))) {
|
||||
$requestPath = substr($requestPath, strlen($basePath));
|
||||
}
|
||||
|
||||
$relative = ltrim($requestPath, '/');
|
||||
if ($requestQuery !== '') {
|
||||
$relative .= '?' . $requestQuery;
|
||||
}
|
||||
|
||||
return self::canonicalizeRelative($relative);
|
||||
}
|
||||
|
||||
private static function normalizePath(string $path): string
|
||||
{
|
||||
$path = ltrim(trim($path), '/');
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$collapsed = preg_replace('#/+#', '/', $path);
|
||||
if ($collapsed === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$segments = explode('/', $collapsed);
|
||||
$normalized = [];
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
$segment = trim($segment);
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($segment === '..') {
|
||||
array_pop($normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $segment;
|
||||
}
|
||||
|
||||
return implode('/', $normalized);
|
||||
}
|
||||
|
||||
private static function normalizeQuery(string $query): string
|
||||
{
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parsed = [];
|
||||
foreach (explode('&', $query) as $pair) {
|
||||
if ($pair === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$separatorPos = strpos($pair, '=');
|
||||
if ($separatorPos === false) {
|
||||
$rawKey = $pair;
|
||||
$rawValue = '';
|
||||
} else {
|
||||
$rawKey = substr($pair, 0, $separatorPos);
|
||||
$rawValue = substr($pair, $separatorPos + 1);
|
||||
}
|
||||
|
||||
$key = rawurldecode(str_replace('+', ' ', $rawKey));
|
||||
$value = rawurldecode(str_replace('+', ' ', $rawValue));
|
||||
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed[$key][] = $value;
|
||||
}
|
||||
|
||||
if ($parsed === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ksort($parsed, SORT_STRING);
|
||||
|
||||
$normalizedPairs = [];
|
||||
foreach ($parsed as $key => $values) {
|
||||
sort($values, SORT_STRING);
|
||||
foreach ($values as $value) {
|
||||
$normalizedPairs[] = rawurlencode($key) . '=' . rawurlencode((string) $value);
|
||||
}
|
||||
}
|
||||
|
||||
return implode('&', $normalizedPairs);
|
||||
}
|
||||
}
|
||||
@@ -628,8 +628,10 @@ function appBuildLayoutNavContext(array $layoutAuth, array $session, array $quer
|
||||
'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''),
|
||||
'activeCustomFilters' => appNormalizeAddressBookCustomFilterQuery($query),
|
||||
'peopleGroups' => is_array($session['available_departments_by_tenant'] ?? null) ? $session['available_departments_by_tenant'] : [],
|
||||
'savedFilters' => is_array($session['address_book_saved_filters'] ?? null) ? $session['address_book_saved_filters'] : [],
|
||||
],
|
||||
'bookmarks' => is_array($session['user_bookmarks'] ?? null)
|
||||
? $session['user_bookmarks']
|
||||
: ['groups' => [], 'ungrouped' => []],
|
||||
'csrfKey' => $csrfKey,
|
||||
'csrfToken' => $csrfToken,
|
||||
];
|
||||
|
||||
@@ -24,19 +24,7 @@ require templatePath('partials/app-breadcrumb.phtml');
|
||||
?>
|
||||
<?php
|
||||
$listTitle = t('Address book');
|
||||
ob_start();
|
||||
?>
|
||||
<button
|
||||
class="outline secondary"
|
||||
type="button"
|
||||
data-address-book-save-filter
|
||||
data-save-filter-prompt="<?php e(t('Enter a name for this filter')); ?>"
|
||||
data-save-filter-empty="<?php e(t('Filter name is required')); ?>"
|
||||
>
|
||||
<i class="bi bi-bookmark-plus"></i> <?php e(t('Save filter')); ?>
|
||||
</button>
|
||||
<?php
|
||||
$listTitleActionsHtml = ob_get_clean();
|
||||
$listTitleActionsHtml = '';
|
||||
require templatePath('partials/app-list-titlebar.phtml');
|
||||
?>
|
||||
<div
|
||||
@@ -223,15 +211,6 @@ require templatePath('partials/app-list-titlebar.phtml');
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
<form id="address-book-save-filter-form" method="post" action="address-book/saved-filter-save" hidden>
|
||||
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
|
||||
<input type="hidden" name="name" value="">
|
||||
<input type="hidden" name="search" value="">
|
||||
<input type="hidden" name="tenants" value="">
|
||||
<input type="hidden" name="departments" value="">
|
||||
<input type="hidden" name="roles" value="">
|
||||
<div data-address-book-save-filter-extra></div>
|
||||
</form>
|
||||
<div class="app-list-table">
|
||||
<div id="address-book-grid"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW);
|
||||
|
||||
$buildAddressBookRedirect = static function (array $query): string {
|
||||
$params = [];
|
||||
$search = trim((string) ($query['search'] ?? ''));
|
||||
$tenants = trim((string) ($query['tenants'] ?? ''));
|
||||
$departments = trim((string) ($query['departments'] ?? ''));
|
||||
$roles = trim((string) ($query['roles'] ?? ''));
|
||||
$collectCustom = static function (array $source): array {
|
||||
$custom = [];
|
||||
foreach ($source as $rawKey => $rawValue) {
|
||||
$key = strtolower(trim((string) $rawKey));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) {
|
||||
$value = trim((string) $rawValue);
|
||||
if ($value !== '') {
|
||||
$custom[$key] = $value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) {
|
||||
$ids = array_values(array_filter(array_map(
|
||||
'intval',
|
||||
array_map('trim', explode(',', (string) $rawValue))
|
||||
), static fn (int $id): bool => $id > 0));
|
||||
$ids = array_values(array_unique($ids));
|
||||
if ($ids) {
|
||||
$custom[$key] = implode(',', $ids);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) {
|
||||
$value = trim((string) $rawValue);
|
||||
if ($value !== '') {
|
||||
$custom[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
ksort($custom, SORT_STRING);
|
||||
return $custom;
|
||||
};
|
||||
|
||||
if ($search !== '') {
|
||||
$params['search'] = $search;
|
||||
}
|
||||
if ($tenants !== '') {
|
||||
$params['tenants'] = $tenants;
|
||||
}
|
||||
if ($departments !== '') {
|
||||
$params['departments'] = $departments;
|
||||
}
|
||||
if ($roles !== '') {
|
||||
$params['roles'] = $roles;
|
||||
}
|
||||
foreach ($collectCustom($query) as $customKey => $customValue) {
|
||||
$params[$customKey] = $customValue;
|
||||
}
|
||||
|
||||
if (!$params) {
|
||||
return 'address-book';
|
||||
}
|
||||
return 'address-book?' . http_build_query($params);
|
||||
};
|
||||
|
||||
$redirect = $buildAddressBookRedirect(requestInput()->bodyAll());
|
||||
$errorBag = formErrors();
|
||||
if ((requestInput()->method()) !== 'POST') {
|
||||
Router::redirect($redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
$errorBag->addGlobal(t('Form expired, please try again'));
|
||||
flashFormErrors($errorBag, 'address-book', 'address_book_saved_filters');
|
||||
Router::redirect($redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
Router::redirect('login');
|
||||
return;
|
||||
}
|
||||
$userSavedFilterService = (app(UserServicesFactory::class))->createUserSavedFilterService();
|
||||
|
||||
$uuid = trim((string) (requestInput()->bodyAll()['uuid'] ?? ''));
|
||||
if ($uuid === '') {
|
||||
Router::redirect($redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = $userSavedFilterService->deleteAddressBookFilter($userId, $uuid);
|
||||
if ($deleted) {
|
||||
$sessionStore->set('address_book_saved_filters', $userSavedFilterService->listForAddressBook($userId));
|
||||
Flash::success(t('Filter deleted'), 'address-book', 'address_book_saved_filter_deleted');
|
||||
}
|
||||
|
||||
Router::redirect($redirect);
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
$sessionStore = app(SessionStoreInterface::class);
|
||||
$session = $sessionStore->all();
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW);
|
||||
|
||||
$buildAddressBookRedirect = static function (array $query): string {
|
||||
$params = [];
|
||||
$search = trim((string) ($query['search'] ?? ''));
|
||||
$tenants = trim((string) ($query['tenants'] ?? ''));
|
||||
$departments = trim((string) ($query['departments'] ?? ''));
|
||||
$roles = trim((string) ($query['roles'] ?? ''));
|
||||
$collectCustom = static function (array $source): array {
|
||||
$custom = [];
|
||||
foreach ($source as $rawKey => $rawValue) {
|
||||
$key = strtolower(trim((string) $rawKey));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) {
|
||||
$value = trim((string) $rawValue);
|
||||
if ($value !== '') {
|
||||
$custom[$key] = $value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) {
|
||||
$ids = array_values(array_filter(array_map(
|
||||
'intval',
|
||||
array_map('trim', explode(',', (string) $rawValue))
|
||||
), static fn (int $id): bool => $id > 0));
|
||||
$ids = array_values(array_unique($ids));
|
||||
if ($ids) {
|
||||
$custom[$key] = implode(',', $ids);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) {
|
||||
$value = trim((string) $rawValue);
|
||||
if ($value !== '') {
|
||||
$custom[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
ksort($custom, SORT_STRING);
|
||||
return $custom;
|
||||
};
|
||||
|
||||
if ($search !== '') {
|
||||
$params['search'] = $search;
|
||||
}
|
||||
if ($tenants !== '') {
|
||||
$params['tenants'] = $tenants;
|
||||
}
|
||||
if ($departments !== '') {
|
||||
$params['departments'] = $departments;
|
||||
}
|
||||
if ($roles !== '') {
|
||||
$params['roles'] = $roles;
|
||||
}
|
||||
foreach ($collectCustom($query) as $customKey => $customValue) {
|
||||
$params[$customKey] = $customValue;
|
||||
}
|
||||
|
||||
if (!$params) {
|
||||
return 'address-book';
|
||||
}
|
||||
return 'address-book?' . http_build_query($params);
|
||||
};
|
||||
|
||||
$redirect = $buildAddressBookRedirect(requestInput()->bodyAll());
|
||||
$errorBag = formErrors();
|
||||
if ((requestInput()->method()) !== 'POST') {
|
||||
Router::redirect($redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
$errorBag->addGlobal(t('Form expired, please try again'));
|
||||
flashFormErrors($errorBag, 'address-book', 'address_book_saved_filters');
|
||||
Router::redirect($redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
Router::redirect('login');
|
||||
return;
|
||||
}
|
||||
$userSavedFilterService = (app(UserServicesFactory::class))->createUserSavedFilterService();
|
||||
|
||||
$name = trim((string) (requestInput()->bodyAll()['name'] ?? ''));
|
||||
$result = $userSavedFilterService->saveAddressBookFilter($userId, $name, requestInput()->bodyAll());
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
$error = (string) ($result['error'] ?? '');
|
||||
if ($error === 'name_required') {
|
||||
$errorBag->addGlobal(t('Filter name is required'));
|
||||
} elseif ($error === 'max_reached') {
|
||||
$errorBag->addGlobal(t('Maximum number of saved filters reached'));
|
||||
} else {
|
||||
$errorBag->addGlobal(t('Filter save failed'));
|
||||
}
|
||||
flashFormErrors($errorBag, 'address-book', 'address_book_saved_filter_save');
|
||||
Router::redirect($redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionStore->set('address_book_saved_filters', $userSavedFilterService->listForAddressBook($userId));
|
||||
Flash::success(t('Filter saved'), 'address-book', 'address_book_saved_filter_saved');
|
||||
Router::redirect($redirect);
|
||||
34
pages/bookmarks/delete-data().php
Normal file
34
pages/bookmarks/delete-data().php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
if (requestInput()->method() !== 'POST') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
http_response_code(403);
|
||||
Router::json(['ok' => false, 'error' => 'csrf']);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
$bookmarkId = (int) requestInput()->body('id');
|
||||
|
||||
$service = app(BookmarkService::class);
|
||||
$deleted = $service->deleteBookmark($userId, $bookmarkId);
|
||||
|
||||
if ($deleted) {
|
||||
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId));
|
||||
}
|
||||
|
||||
Router::json(['ok' => $deleted]);
|
||||
42
pages/bookmarks/group-delete-data().php
Normal file
42
pages/bookmarks/group-delete-data().php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
if (requestInput()->method() !== 'POST') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
http_response_code(403);
|
||||
Router::json(['ok' => false, 'error' => 'csrf']);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
$groupId = (int) requestInput()->body('id');
|
||||
if ($groupId <= 0) {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'invalid_group_id']);
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BookmarkService::class);
|
||||
$deleted = $service->deleteGroup($userId, $groupId);
|
||||
|
||||
if ($deleted) {
|
||||
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId));
|
||||
Router::json(['ok' => true]);
|
||||
return;
|
||||
}
|
||||
|
||||
http_response_code(404);
|
||||
Router::json(['ok' => false, 'error' => 'not_found']);
|
||||
37
pages/bookmarks/group-save-data().php
Normal file
37
pages/bookmarks/group-save-data().php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
if (requestInput()->method() !== 'POST') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
http_response_code(403);
|
||||
Router::json(['ok' => false, 'error' => 'csrf']);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
$name = trim((string) requestInput()->body('name'));
|
||||
$icon = trim((string) requestInput()->body('icon'));
|
||||
$groupId = requestInput()->body('id');
|
||||
$groupId = ($groupId !== null && $groupId !== '') ? (int) $groupId : null;
|
||||
|
||||
$service = app(BookmarkService::class);
|
||||
$result = $service->saveGroup($userId, $name, $icon, $groupId);
|
||||
|
||||
if ($result['ok']) {
|
||||
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId));
|
||||
}
|
||||
|
||||
Router::json($result);
|
||||
122
pages/bookmarks/reorder-data().php
Normal file
122
pages/bookmarks/reorder-data().php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
if (requestInput()->method() !== 'POST') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
http_response_code(403);
|
||||
Router::json(['ok' => false, 'error' => 'csrf']);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
$type = trim((string) requestInput()->body('type'));
|
||||
$allowedTypes = ['groups', 'bookmarks', 'root'];
|
||||
if (!in_array($type, $allowedTypes, true)) {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'invalid_type']);
|
||||
return;
|
||||
}
|
||||
|
||||
$rawItems = requestInput()->body('items');
|
||||
$items = is_array($rawItems) ? $rawItems : (is_string($rawItems) ? json_decode($rawItems, true) : []);
|
||||
|
||||
if (!is_array($items)) {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(BookmarkService::class);
|
||||
|
||||
if ($type === 'root') {
|
||||
$rootPairs = [];
|
||||
$seenRootIds = [];
|
||||
$seenSortOrders = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$kind = trim((string) ($item['kind'] ?? ''));
|
||||
$id = (int) ($item['id'] ?? 0);
|
||||
$sortOrder = (int) ($item['sort_order'] ?? 0);
|
||||
if (($kind !== 'group' && $kind !== 'bookmark') || $id <= 0 || $sortOrder <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dedupeKey = $kind . ':' . $id;
|
||||
if (isset($seenRootIds[$dedupeKey]) || isset($seenSortOrders[$sortOrder])) {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||
return;
|
||||
}
|
||||
$seenRootIds[$dedupeKey] = true;
|
||||
$seenSortOrders[$sortOrder] = true;
|
||||
$rootPairs[] = [
|
||||
'kind' => $kind,
|
||||
'id' => $id,
|
||||
'sort_order' => $sortOrder,
|
||||
];
|
||||
}
|
||||
|
||||
$pairCount = count($rootPairs);
|
||||
if ($pairCount === 0 || $pairCount !== count($items)) {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ok = $service->reorderRoot($userId, $rootPairs);
|
||||
} else {
|
||||
$pairs = [];
|
||||
$seenIds = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$id = (int) ($item['id'] ?? 0);
|
||||
$sortOrder = (int) ($item['sort_order'] ?? 0);
|
||||
if ($id > 0 && $sortOrder > 0) {
|
||||
if (isset($seenIds[$id])) {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||
return;
|
||||
}
|
||||
$seenIds[$id] = true;
|
||||
$pairs[] = ['id' => $id, 'sort_order' => $sortOrder];
|
||||
}
|
||||
}
|
||||
|
||||
$pairCount = count($pairs);
|
||||
if ($pairCount === 0 || $pairCount !== count($items)) {
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'invalid_items']);
|
||||
return;
|
||||
}
|
||||
|
||||
$ok = $type === 'groups'
|
||||
? $service->reorderGroups($userId, $pairs)
|
||||
: $service->reorderBookmarks($userId, $pairs);
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId));
|
||||
Router::json(['ok' => true]);
|
||||
return;
|
||||
}
|
||||
|
||||
http_response_code(400);
|
||||
Router::json(['ok' => false, 'error' => 'reorder_failed']);
|
||||
37
pages/bookmarks/save-data().php
Normal file
37
pages/bookmarks/save-data().php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
if (requestInput()->method() !== 'POST') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
http_response_code(403);
|
||||
Router::json(['ok' => false, 'error' => 'csrf']);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
$name = trim((string) requestInput()->body('name'));
|
||||
$url = trim((string) requestInput()->body('url'));
|
||||
$groupId = requestInput()->body('group_id');
|
||||
$groupId = ($groupId !== null && $groupId !== '') ? (int) $groupId : null;
|
||||
|
||||
$service = app(BookmarkService::class);
|
||||
$result = $service->saveBookmark($userId, $name, $url, $groupId);
|
||||
|
||||
if ($result['ok']) {
|
||||
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId));
|
||||
}
|
||||
|
||||
Router::json($result);
|
||||
42
pages/bookmarks/update-data().php
Normal file
42
pages/bookmarks/update-data().php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
|
||||
if (requestInput()->method() !== 'POST') {
|
||||
http_response_code(405);
|
||||
Router::json(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Session::checkCsrfToken()) {
|
||||
http_response_code(403);
|
||||
Router::json(['ok' => false, 'error' => 'csrf']);
|
||||
return;
|
||||
}
|
||||
|
||||
$session = app(SessionStoreInterface::class)->all();
|
||||
$userId = (int) ($session['user']['id'] ?? 0);
|
||||
$bookmarkId = (int) requestInput()->body('id');
|
||||
|
||||
$data = [];
|
||||
if (requestInput()->body('name') !== null) {
|
||||
$data['name'] = (string) requestInput()->body('name');
|
||||
}
|
||||
if (requestInput()->body('group_id') !== null) {
|
||||
$data['group_id'] = requestInput()->body('group_id');
|
||||
}
|
||||
|
||||
$service = app(BookmarkService::class);
|
||||
$result = $service->updateBookmark($userId, $bookmarkId, $data);
|
||||
|
||||
if ($result['ok']) {
|
||||
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId));
|
||||
}
|
||||
|
||||
Router::json($result);
|
||||
@@ -86,7 +86,9 @@ if ($bufferTitle !== '') {
|
||||
<?php require __DIR__ . '/partials/app-confirm-dialog.phtml'; ?>
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<?php require __DIR__ . '/partials/app-session-warning-dialog.phtml'; ?>
|
||||
<?php require __DIR__ . '/partials/app-bookmark-dialog.phtml'; ?>
|
||||
<script type="module" src="<?php e(assetVersion('js/components/app-session-warning.js')); ?>"></script>
|
||||
<script type="module" src="<?php e(assetVersion('js/components/app-bookmark-init.js')); ?>"></script>
|
||||
<?php endif; ?>
|
||||
<script type="module" src="<?php e(assetVersion('js/app-init.js')); ?>"></script>
|
||||
</body>
|
||||
|
||||
94
templates/partials/app-bookmark-dialog.phtml
Normal file
94
templates/partials/app-bookmark-dialog.phtml
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
|
||||
$bmSessionData = is_array($_SESSION['user_bookmarks'] ?? null) ? $_SESSION['user_bookmarks'] : [];
|
||||
$bmSessionGroups = is_array($bmSessionData['groups'] ?? null) ? $bmSessionData['groups'] : [];
|
||||
|
||||
?>
|
||||
<dialog
|
||||
data-app-bookmark-dialog
|
||||
data-max-message="<?php e(t('Maximum bookmarks reached')); ?>"
|
||||
data-title-create="<?php e(t('Bookmarks')); ?>"
|
||||
data-title-edit="<?php e(t('Bookmarks')); ?>"
|
||||
data-label-save="<?php e(t('Save')); ?>"
|
||||
data-label-update="<?php e(t('Save')); ?>"
|
||||
data-msg-saved="<?php e(t('Bookmark saved')); ?>"
|
||||
data-msg-updated="<?php e(t('Bookmark updated')); ?>"
|
||||
data-msg-group-created="<?php e(t('Group created')); ?>"
|
||||
data-msg-error-generic="<?php e(t('Bookmark action failed')); ?>"
|
||||
data-msg-group-create-failed="<?php e(t('Group action failed')); ?>"
|
||||
data-msg-name-required="<?php e(t('Bookmark name is required')); ?>"
|
||||
data-msg-url-required="<?php e(t('Bookmark URL is invalid')); ?>"
|
||||
data-msg-group-not-found="<?php e(t('Group not found')); ?>"
|
||||
aria-labelledby="app-bookmark-dialog-title"
|
||||
>
|
||||
<article class="app-bookmark-dialog">
|
||||
<header>
|
||||
<h2 id="app-bookmark-dialog-title"><?php e(t('Bookmarks')); ?></h2>
|
||||
<button type="button" class="close" data-bookmark-dialog-close aria-label="<?php e(t('Close')); ?>" title="<?php e(t('Close')); ?>"></button>
|
||||
</header>
|
||||
<form data-bookmark-save-form>
|
||||
<label for="bookmark-name"><?php e(t('Name')); ?></label>
|
||||
<input type="text" id="bookmark-name" name="name" maxlength="120" required
|
||||
placeholder="<?php e(t('Bookmark name')); ?>">
|
||||
<label for="bookmark-group"><?php e(t('Assign to group')); ?></label>
|
||||
<select id="bookmark-group" name="group_id" data-bookmark-group-select>
|
||||
<option value=""><?php e(t('No group')); ?></option>
|
||||
<?php foreach ($bmSessionGroups as $grp):
|
||||
$grpId = (int) ($grp['id'] ?? 0);
|
||||
$grpName = trim((string) ($grp['name'] ?? ''));
|
||||
if ($grpId <= 0 || $grpName === '') { continue; }
|
||||
?>
|
||||
<option value="<?php e($grpId); ?>"><?php e($grpName); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="button" class="outline secondary app-bookmark-new-group-btn" data-bookmark-new-group>
|
||||
<i class="bi bi-plus"></i> <?php e(t('New group')); ?>
|
||||
</button>
|
||||
<footer>
|
||||
<button type="submit" class="app-action-success" data-bookmark-submit-label><?php e(t('Save')); ?></button>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<dialog
|
||||
data-app-bookmark-group-dialog
|
||||
data-title-create="<?php e(t('New group')); ?>"
|
||||
data-title-edit="<?php e(t('Edit group')); ?>"
|
||||
data-label-save="<?php e(t('Save')); ?>"
|
||||
data-label-update="<?php e(t('Save')); ?>"
|
||||
data-max-message="<?php e(t('Maximum groups reached')); ?>"
|
||||
data-msg-created="<?php e(t('Group created')); ?>"
|
||||
data-msg-updated="<?php e(t('Group updated')); ?>"
|
||||
data-msg-error-generic="<?php e(t('Group action failed')); ?>"
|
||||
data-msg-name-required="<?php e(t('Group name is required')); ?>"
|
||||
aria-labelledby="app-bookmark-group-dialog-title"
|
||||
>
|
||||
<article class="app-bookmark-dialog app-bookmark-group-dialog">
|
||||
<header>
|
||||
<h2 id="app-bookmark-group-dialog-title"><?php e(t('New group')); ?></h2>
|
||||
<button type="button" class="close" data-bookmark-group-dialog-close aria-label="<?php e(t('Close')); ?>" title="<?php e(t('Close')); ?>"></button>
|
||||
</header>
|
||||
<form data-bookmark-group-save-form>
|
||||
<label for="bookmark-group-name"><?php e(t('Group name')); ?></label>
|
||||
<input type="text" id="bookmark-group-name" name="name" maxlength="100" required
|
||||
placeholder="<?php e(t('Group name')); ?>">
|
||||
<label><?php e(t('Icon')); ?></label>
|
||||
<div class="app-bookmark-group-icon-picker" data-bookmark-group-icon-picker>
|
||||
<?php foreach (BookmarkService::allowedGroupIcons() as $allowedIcon): ?>
|
||||
<button type="button" class="app-bookmark-group-icon-option"
|
||||
data-icon="<?php e($allowedIcon); ?>"
|
||||
aria-label="<?php e($allowedIcon); ?>">
|
||||
<i class="bi <?php e($allowedIcon); ?>"></i>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<input type="hidden" name="icon" value="bi-folder">
|
||||
<footer>
|
||||
<button type="submit" class="app-action-success" data-bookmark-group-submit-label><?php e(t('Save')); ?></button>
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
</dialog>
|
||||
@@ -35,10 +35,11 @@ $addressBookUrl = lurl('address-book');
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<button type="button" id="aside-tab-files" data-aside-target="files" role="tab" aria-selected="false"
|
||||
aria-controls="aside-panel-files" aria-label="<?php e(t('Files')); ?>"
|
||||
data-tooltip="<?php e(t('Files')); ?>" data-tooltip-pos="right">
|
||||
<i class="bi bi-folder"></i>
|
||||
<button type="button" id="aside-tab-bookmarks" data-aside-target="bookmarks" role="tab"
|
||||
aria-selected="false" aria-controls="aside-panel-bookmarks"
|
||||
aria-label="<?php e(t('Bookmarks')); ?>"
|
||||
data-tooltip="<?php e(t('Bookmarks')); ?>" data-tooltip-pos="right">
|
||||
<i class="bi bi-bookmark-star"></i>
|
||||
</button>
|
||||
</li>
|
||||
<?php if ($hasAdminPanel): ?>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Service\Docs\DocsCatalogService;
|
||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
||||
|
||||
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
|
||||
$canViewTenants = (bool) ($layoutAuth['can_view_tenants'] ?? false);
|
||||
@@ -261,7 +262,6 @@ $activeAddressDepartments = is_array($addressBook['activeDepartments'] ?? null)
|
||||
$activeAddressRoles = is_array($addressBook['activeRoles'] ?? null) ? $addressBook['activeRoles'] : [];
|
||||
$activeAddressCustomFilters = is_array($addressBook['activeCustomFilters'] ?? null) ? $addressBook['activeCustomFilters'] : [];
|
||||
$peopleGroups = is_array($addressBook['peopleGroups'] ?? null) ? $addressBook['peopleGroups'] : [];
|
||||
$savedAddressFilters = is_array($addressBook['savedFilters'] ?? null) ? $addressBook['savedFilters'] : [];
|
||||
$csrfKey = trim((string) ($layoutNav['csrfKey'] ?? \MintyPHP\Session::$csrfSessionKey));
|
||||
$csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
?>
|
||||
@@ -315,87 +315,6 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
data-aside-details-storage="aside-people-tenant"
|
||||
data-aside-title="<?php e(t('Address book')); ?>" role="tabpanel" aria-labelledby="aside-tab-people" hidden>
|
||||
<ul>
|
||||
<?php if ($savedAddressFilters): ?>
|
||||
<li class="app-sidebar-group app-sidebar-saved-filters">
|
||||
<small class="muted"><?php e(t('Saved filters')); ?></small>
|
||||
<ul class="app-sidebar-saved-filter-list">
|
||||
<?php foreach ($savedAddressFilters as $savedFilter): ?>
|
||||
<?php
|
||||
$savedUuid = trim((string) ($savedFilter['uuid'] ?? ''));
|
||||
$savedName = trim((string) ($savedFilter['name'] ?? ''));
|
||||
$savedQuery = $savedFilter['query'] ?? [];
|
||||
$savedQuery = is_array($savedQuery) ? $savedQuery : [];
|
||||
if ($savedUuid === '' || $savedName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$savedSearch = trim((string) ($savedQuery['search'] ?? ''));
|
||||
$savedTenants = appNormalizeStringList($savedQuery['tenants'] ?? []);
|
||||
$savedDepartments = appNormalizePositiveIntList($savedQuery['departments'] ?? []);
|
||||
$savedRoles = appNormalizePositiveIntList($savedQuery['roles'] ?? []);
|
||||
$savedCustomFilters = appNormalizeAddressBookCustomFilterQuery($savedQuery);
|
||||
|
||||
$savedQueryParams = [];
|
||||
if ($savedSearch !== '') {
|
||||
$savedQueryParams['search'] = $savedSearch;
|
||||
}
|
||||
if ($savedTenants) {
|
||||
$savedQueryParams['tenants'] = implode(',', $savedTenants);
|
||||
}
|
||||
if ($savedDepartments) {
|
||||
$savedQueryParams['departments'] = implode(',', $savedDepartments);
|
||||
}
|
||||
if ($savedRoles) {
|
||||
$savedQueryParams['roles'] = implode(',', $savedRoles);
|
||||
}
|
||||
foreach ($savedCustomFilters as $customKey => $customValue) {
|
||||
if (is_array($customValue)) {
|
||||
$savedQueryParams[$customKey] = implode(',', $customValue);
|
||||
} else {
|
||||
$savedQueryParams[$customKey] = (string) $customValue;
|
||||
}
|
||||
}
|
||||
|
||||
$savedHref = $addressBookUrl;
|
||||
if ($savedQueryParams) {
|
||||
$savedHref .= '?' . http_build_query($savedQueryParams);
|
||||
}
|
||||
|
||||
$isSavedFilterActive = $addressBookActive['isActive']
|
||||
&& $savedSearch === $activeAddressSearch
|
||||
&& $savedTenants === $activeAddressTenants
|
||||
&& $savedDepartments === $activeAddressDepartments
|
||||
&& $savedRoles === $activeAddressRoles
|
||||
&& $savedCustomFilters === $activeAddressCustomFilters;
|
||||
?>
|
||||
<li class="app-sidebar-saved-filter-item">
|
||||
<a href="<?php e($savedHref); ?>" class="<?php e($isSavedFilterActive ? 'active' : ''); ?>"
|
||||
<?php echo $isSavedFilterActive ? 'aria-current="page"' : ''; ?>>
|
||||
<?php e($savedName); ?>
|
||||
</a>
|
||||
<form method="post" action="<?php e(lurl('address-book/saved-filter-delete')); ?>"
|
||||
class="app-sidebar-saved-filter-delete"
|
||||
data-confirm-message="<?php e(t('Delete saved filter')); ?>?">
|
||||
<input type="hidden" name="<?php e($csrfKey); ?>" value="<?php e($csrfToken); ?>">
|
||||
<input type="hidden" name="uuid" value="<?php e($savedUuid); ?>">
|
||||
<input type="hidden" name="search" value="<?php e($activeAddressSearch); ?>">
|
||||
<input type="hidden" name="tenants" value="<?php e(implode(',', $activeAddressTenants)); ?>">
|
||||
<input type="hidden" name="departments" value="<?php e(implode(',', $activeAddressDepartments)); ?>">
|
||||
<input type="hidden" name="roles" value="<?php e(implode(',', $activeAddressRoles)); ?>">
|
||||
<?php foreach ($activeAddressCustomFilters as $customKey => $customValue): ?>
|
||||
<input type="hidden" name="<?php e($customKey); ?>" value="<?php e(is_array($customValue) ? implode(',', $customValue) : (string) $customValue); ?>">
|
||||
<?php endforeach; ?>
|
||||
<button type="submit" class="transparent"
|
||||
title="<?php e(t('Delete saved filter')); ?>"
|
||||
aria-label="<?php e(t('Delete saved filter')); ?>">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if (!$peopleGroups): ?>
|
||||
<li>
|
||||
<a href="<?php e($addressBookUrl); ?>" class="<?php e($addressBookActive['class'] ?? ''); ?>"
|
||||
@@ -620,24 +539,242 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<nav id="aside-panel-files" class="app-sidebar-panel" data-aside-panel="files"
|
||||
data-aside-title="<?php e(t('Files')); ?>" role="tabpanel" aria-labelledby="aside-tab-files" hidden>
|
||||
<ul>
|
||||
<?php
|
||||
$bookmarkData = is_array($layoutNav['bookmarks'] ?? null) ? $layoutNav['bookmarks'] : ['groups' => [], 'ungrouped' => []];
|
||||
$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : [];
|
||||
$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : [];
|
||||
$currentPath = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase());
|
||||
?>
|
||||
<nav id="aside-panel-bookmarks" class="app-sidebar-panel" data-aside-panel="bookmarks"
|
||||
data-aside-title="<?php e(t('Bookmarks')); ?>"
|
||||
data-aside-details-storage="aside-bookmarks-groups-v1"
|
||||
data-bookmark-reorder-url="<?php e(lurl('bookmarks/reorder-data')); ?>"
|
||||
data-bookmark-group-delete-url="<?php e(lurl('bookmarks/group-delete-data')); ?>"
|
||||
data-bookmark-delete-url="<?php e(lurl('bookmarks/delete-data')); ?>"
|
||||
data-bookmark-msg-group-delete-confirm="<?php e(t('Delete group and keep bookmarks?')); ?>"
|
||||
data-bookmark-msg-group-deleted="<?php e(t('Group deleted')); ?>"
|
||||
data-bookmark-msg-group-updated="<?php e(t('Group updated')); ?>"
|
||||
data-bookmark-msg-bookmark-delete-confirm="<?php e(t('Delete this bookmark?')); ?>"
|
||||
data-bookmark-msg-bookmark-deleted="<?php e(t('Bookmark deleted')); ?>"
|
||||
data-bookmark-msg-group-action-failed="<?php e(t('Group action failed')); ?>"
|
||||
data-bookmark-msg-bookmark-action-failed="<?php e(t('Bookmark action failed')); ?>"
|
||||
data-bookmark-msg-group-delete-failed="<?php e(t('Group delete failed')); ?>"
|
||||
data-bookmark-msg-bookmark-delete-failed="<?php e(t('Bookmark action failed')); ?>"
|
||||
data-bookmark-msg-reorder-saved="<?php e(t('Reorder saved')); ?>"
|
||||
data-bookmark-msg-reorder-failed="<?php e(t('Reorder failed')); ?>"
|
||||
data-bookmark-label-delete="<?php e(t('Delete')); ?>"
|
||||
role="tabpanel" aria-labelledby="aside-tab-bookmarks" hidden>
|
||||
<?php if (!$bookmarkGroups && !$bookmarkUngrouped): ?>
|
||||
<?php
|
||||
$emptyState = [
|
||||
'message' => t('No bookmarks yet'),
|
||||
'size' => 'compact',
|
||||
'align' => 'center',
|
||||
];
|
||||
require templatePath('partials/app-empty-state.phtml');
|
||||
?>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$bookmarkRootItems = [];
|
||||
foreach ($bookmarkUngrouped as $bm) {
|
||||
$bmId = (int) ($bm['id'] ?? 0);
|
||||
if ($bmId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$bookmarkRootItems[] = [
|
||||
'type' => 'bookmark',
|
||||
'id' => $bmId,
|
||||
'sort_order' => (int) ($bm['sort_order'] ?? 0),
|
||||
'bookmark' => $bm,
|
||||
];
|
||||
}
|
||||
foreach ($bookmarkGroups as $group) {
|
||||
$gId = (int) ($group['id'] ?? 0);
|
||||
if ($gId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$bookmarkRootItems[] = [
|
||||
'type' => 'group',
|
||||
'id' => $gId,
|
||||
'sort_order' => (int) ($group['sort_order'] ?? 0),
|
||||
'group' => $group,
|
||||
];
|
||||
}
|
||||
usort($bookmarkRootItems, static function (array $a, array $b): int {
|
||||
$sortCmp = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
|
||||
if ($sortCmp !== 0) {
|
||||
return $sortCmp;
|
||||
}
|
||||
$typeCmp = strcmp((string) ($a['type'] ?? ''), (string) ($b['type'] ?? ''));
|
||||
if ($typeCmp !== 0) {
|
||||
return $typeCmp;
|
||||
}
|
||||
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
|
||||
});
|
||||
$rootCount = count($bookmarkRootItems);
|
||||
?>
|
||||
<ul data-bookmark-root-list>
|
||||
<?php foreach ($bookmarkRootItems as $rootIndex => $rootItem): ?>
|
||||
<?php
|
||||
$rootType = (string) ($rootItem['type'] ?? '');
|
||||
$rootFirst = $rootIndex === 0;
|
||||
$rootLast = $rootIndex === ($rootCount - 1);
|
||||
?>
|
||||
<?php if ($rootType === 'bookmark'): ?>
|
||||
<?php
|
||||
$bm = is_array($rootItem['bookmark'] ?? null) ? $rootItem['bookmark'] : [];
|
||||
$bmId = (int) ($bm['id'] ?? 0);
|
||||
$bmName = trim((string) ($bm['name'] ?? ''));
|
||||
$bmUrl = trim((string) ($bm['url'] ?? ''));
|
||||
$bmCanonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bmUrl);
|
||||
$bmActive = $bmCanonicalUrl !== '' && $currentPath === $bmCanonicalUrl;
|
||||
?>
|
||||
<li class="app-sidebar-bookmark-root-item app-sidebar-bookmark-item"
|
||||
data-bookmark-root-kind="bookmark"
|
||||
data-bookmark-root-id="<?php e($bmId); ?>"
|
||||
data-bookmark-id="<?php e($bmId); ?>"
|
||||
data-bookmark-name="<?php e($bmName); ?>"
|
||||
data-bookmark-group-id="">
|
||||
<a href="<?php e(lurl($bmUrl)); ?>"
|
||||
class="<?php e($bmActive ? 'active' : ''); ?>"
|
||||
<?php echo $bmActive ? 'aria-current="page"' : ''; ?>>
|
||||
<?php e($bmName); ?>
|
||||
</a>
|
||||
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
|
||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</summary>
|
||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
|
||||
<li>
|
||||
<a href="#"><?php e(t('All files')); ?></a>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="edit">
|
||||
<?php e(t('Edit')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"><?php e(t('My files')); ?></a>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-up" data-bookmark-item-action-scope="root" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark up')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"><?php e(t('Shared with me')); ?></a>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-down" data-bookmark-item-action-scope="root" <?php if ($rootLast): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark down')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
|
||||
<?php e(t('Delete bookmark')); ?>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="app-sidebar-panel-tools" data-aside-panel-tools hidden>
|
||||
<button type="button" class="icon-button" aria-label="<?php e(t('New folder')); ?>">
|
||||
<i class="bi bi-folder-plus"></i>
|
||||
</details>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$group = is_array($rootItem['group'] ?? null) ? $rootItem['group'] : [];
|
||||
$gId = (int) ($group['id'] ?? 0);
|
||||
$gName = trim((string) ($group['name'] ?? ''));
|
||||
$gIcon = trim((string) ($group['icon'] ?? 'bi-folder'));
|
||||
$gBookmarks = is_array($group['bookmarks'] ?? null) ? $group['bookmarks'] : [];
|
||||
?>
|
||||
<li class="app-sidebar-group app-sidebar-bookmark-group app-sidebar-bookmark-root-item"
|
||||
data-bookmark-root-kind="group"
|
||||
data-bookmark-root-id="<?php e($gId); ?>"
|
||||
data-bookmark-group-id="<?php e($gId); ?>"
|
||||
data-bookmark-group-name="<?php e($gName); ?>"
|
||||
data-bookmark-group-icon="<?php e($gIcon); ?>">
|
||||
<div class="app-sidebar-bookmark-group-shell">
|
||||
<div class="app-sidebar-bookmark-group-header">
|
||||
<small class="app-sidebar-bookmark-group-label">
|
||||
<i class="bi <?php e($gIcon); ?>" aria-hidden="true"></i>
|
||||
<span><?php e($gName); ?></span>
|
||||
</small>
|
||||
<details class="app-sidebar-bookmark-action-menu app-sidebar-bookmark-group-menu" data-bookmark-action-menu>
|
||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</summary>
|
||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Group actions')); ?>">
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-group-action="edit">
|
||||
<?php e(t('Edit')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-group-action="move-up" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move group up')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-group-action="move-down" <?php if ($rootLast): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move group down')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-group-action="delete">
|
||||
<?php e(t('Delete group')); ?>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<?php if ($gBookmarks): ?>
|
||||
<ul data-bookmark-group-bookmark-list>
|
||||
<?php $groupBookmarkCount = count($gBookmarks); ?>
|
||||
<?php foreach ($gBookmarks as $bookmarkIndex => $bm): ?>
|
||||
<?php
|
||||
$bmId = (int) ($bm['id'] ?? 0);
|
||||
$bmName = trim((string) ($bm['name'] ?? ''));
|
||||
$bmUrl = trim((string) ($bm['url'] ?? ''));
|
||||
$bmCanonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bmUrl);
|
||||
$bmActive = $bmCanonicalUrl !== '' && $currentPath === $bmCanonicalUrl;
|
||||
$bookmarkFirst = $bookmarkIndex === 0;
|
||||
$bookmarkLast = $bookmarkIndex === ($groupBookmarkCount - 1);
|
||||
?>
|
||||
<li class="app-sidebar-bookmark-item"
|
||||
data-bookmark-id="<?php e($bmId); ?>"
|
||||
data-bookmark-name="<?php e($bmName); ?>"
|
||||
data-bookmark-group-id="<?php e($gId); ?>">
|
||||
<a href="<?php e(lurl($bmUrl)); ?>"
|
||||
class="<?php e($bmActive ? 'active' : ''); ?>"
|
||||
<?php echo $bmActive ? 'aria-current="page"' : ''; ?>>
|
||||
<?php e($bmName); ?>
|
||||
</a>
|
||||
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
|
||||
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
|
||||
<i class="bi bi-three-dots"></i>
|
||||
</summary>
|
||||
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="edit">
|
||||
<?php e(t('Edit')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-up" <?php if ($bookmarkFirst): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark up')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" data-bookmark-item-action="move-down" <?php if ($bookmarkLast): ?>disabled<?php endif; ?>>
|
||||
<?php e(t('Move bookmark down')); ?>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
|
||||
<?php e(t('Delete bookmark')); ?>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Session;
|
||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
||||
|
||||
$user = $_SESSION['user'] ?? [];
|
||||
$accountUrl = accountUrl();
|
||||
@@ -22,6 +23,23 @@ if ($tenantLabel === '') {
|
||||
$canSwitchTenant = is_array($currentTenant) && count($availableTenants) > 1;
|
||||
$allowUserTheme = allowUserTheme();
|
||||
|
||||
// Bookmark toggle: check if current page is already bookmarked
|
||||
$bmData = is_array($_SESSION['user_bookmarks'] ?? null) ? $_SESSION['user_bookmarks'] : ['groups' => [], 'ungrouped' => []];
|
||||
$bmCurrentUrl = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase());
|
||||
$bmExisting = null;
|
||||
foreach (($bmData['ungrouped'] ?? []) as $bm) {
|
||||
$bmUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? ''));
|
||||
if ($bmUrl !== '' && $bmUrl === $bmCurrentUrl) { $bmExisting = $bm; break; }
|
||||
}
|
||||
if ($bmExisting === null) {
|
||||
foreach (($bmData['groups'] ?? []) as $grp) {
|
||||
foreach (($grp['bookmarks'] ?? []) as $bm) {
|
||||
$bmUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? ''));
|
||||
if ($bmUrl !== '' && $bmUrl === $bmCurrentUrl) { $bmExisting = $bm; break 2; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
<header class="app-header">
|
||||
<nav class="app-topbar">
|
||||
@@ -75,6 +93,18 @@ $allowUserTheme = allowUserTheme();
|
||||
</details>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<li>
|
||||
<button type="button" data-bookmark-open-dialog
|
||||
aria-label="<?php e($bmExisting ? t('Edit bookmark') : t('Bookmarks')); ?>"
|
||||
data-tooltip="<?php e($bmExisting ? t('Edit bookmark') : t('Bookmarks')); ?>" data-tooltip-pos="bottom"
|
||||
<?php if ($bmExisting): ?>
|
||||
data-bookmark-existing-id="<?php e((int) ($bmExisting['id'] ?? 0)); ?>"
|
||||
data-bookmark-existing-name="<?php e(trim((string) ($bmExisting['name'] ?? ''))); ?>"
|
||||
data-bookmark-existing-group="<?php e($bmExisting['group_id'] ?? ''); ?>"
|
||||
<?php endif; ?>>
|
||||
<i class="bi <?php e($bmExisting ? 'bi-bookmark-check-fill' : 'bi-bookmark-plus'); ?>"></i>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<details class="dropdown app-topbar-user-menu"
|
||||
<?php if ($allowUserTheme): ?>
|
||||
|
||||
@@ -62,12 +62,11 @@ class FilterDrawerContractTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testAddressBookSaveFilterReadsAppliedGridState(): void
|
||||
public function testAddressBookFilterChipsContract(): void
|
||||
{
|
||||
$content = $this->readProjectFile('pages/address-book/index(default).phtml');
|
||||
$moduleContent = $this->readProjectFile('web/js/pages/address-book-index.js');
|
||||
|
||||
$this->assertStringContainsString('new URL(gridConfig.baseUrl()).searchParams', $moduleContent);
|
||||
$this->assertStringNotContainsString('const state = collectFilterState();', $moduleContent);
|
||||
$this->assertStringContainsString('data-filter-chips-clear-all-label', $content);
|
||||
$this->assertStringContainsString('data-filter-chips-remove-label', $content);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace MintyPHP\Tests\Service\Auth;
|
||||
|
||||
use MintyPHP\Http\SessionStore;
|
||||
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
|
||||
use MintyPHP\Service\User\UserSavedFilterService;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -24,12 +24,12 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
||||
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn([]);
|
||||
$userTenantContextService->expects($this->never())->method('getCurrentTenant');
|
||||
|
||||
$savedFilterGateway = $this->createMock(UserSavedFilterService::class);
|
||||
$savedFilterGateway->method('listForAddressBook')->willReturn([]);
|
||||
$bookmarkService = $this->createMock(BookmarkService::class);
|
||||
$bookmarkService->method('listGroupedForUser')->willReturn(['groups' => [], 'ungrouped' => []]);
|
||||
|
||||
$service = new AuthSessionTenantContextService(
|
||||
$userTenantContextService,
|
||||
$savedFilterGateway,
|
||||
$bookmarkService,
|
||||
new SessionStore()
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
||||
|
||||
$this->assertSame([], $_SESSION['available_tenants']);
|
||||
$this->assertSame([], $_SESSION['available_departments_by_tenant']);
|
||||
$this->assertSame([], $_SESSION['address_book_saved_filters']);
|
||||
$this->assertSame(['groups' => [], 'ungrouped' => []], $_SESSION['user_bookmarks']);
|
||||
$this->assertTrue((bool) $_SESSION['no_active_tenant']);
|
||||
$this->assertArrayNotHasKey('current_tenant', $_SESSION);
|
||||
}
|
||||
@@ -49,19 +49,19 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
||||
['id' => 8, 'uuid' => 'tenant-8'],
|
||||
];
|
||||
$availableDepartments = ['7' => [['id' => 11]]];
|
||||
$savedFilters = [['name' => 'Favorites']];
|
||||
$bookmarks = ['groups' => [['id' => 1, 'name' => 'Dev', 'bookmarks' => []]], 'ungrouped' => []];
|
||||
|
||||
$userTenantContextService = $this->createMock(UserTenantContextService::class);
|
||||
$userTenantContextService->method('getAvailableTenants')->willReturn($availableTenants);
|
||||
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn($availableDepartments);
|
||||
$userTenantContextService->method('getCurrentTenant')->willReturn(null);
|
||||
|
||||
$savedFilterGateway = $this->createMock(UserSavedFilterService::class);
|
||||
$savedFilterGateway->method('listForAddressBook')->willReturn($savedFilters);
|
||||
$bookmarkService = $this->createMock(BookmarkService::class);
|
||||
$bookmarkService->method('listGroupedForUser')->willReturn($bookmarks);
|
||||
|
||||
$service = new AuthSessionTenantContextService(
|
||||
$userTenantContextService,
|
||||
$savedFilterGateway,
|
||||
$bookmarkService,
|
||||
new SessionStore()
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
||||
|
||||
$this->assertSame($availableTenants, $_SESSION['available_tenants']);
|
||||
$this->assertSame($availableDepartments, $_SESSION['available_departments_by_tenant']);
|
||||
$this->assertSame($savedFilters, $_SESSION['address_book_saved_filters']);
|
||||
$this->assertSame($bookmarks, $_SESSION['user_bookmarks']);
|
||||
$this->assertFalse((bool) $_SESSION['no_active_tenant']);
|
||||
$this->assertSame(7, (int) ($_SESSION['current_tenant']['id'] ?? 0));
|
||||
}
|
||||
@@ -90,12 +90,12 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
||||
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn([]);
|
||||
$userTenantContextService->method('getCurrentTenant')->willReturn($availableTenants[0]);
|
||||
|
||||
$savedFilterGateway = $this->createMock(UserSavedFilterService::class);
|
||||
$savedFilterGateway->method('listForAddressBook')->willReturn([]);
|
||||
$bookmarkService = $this->createMock(BookmarkService::class);
|
||||
$bookmarkService->method('listGroupedForUser')->willReturn(['groups' => [], 'ungrouped' => []]);
|
||||
|
||||
$service = new AuthSessionTenantContextService(
|
||||
$userTenantContextService,
|
||||
$savedFilterGateway,
|
||||
$bookmarkService,
|
||||
new SessionStore()
|
||||
);
|
||||
|
||||
|
||||
423
tests/Service/Bookmark/BookmarkServiceTest.php
Normal file
423
tests/Service/Bookmark/BookmarkServiceTest.php
Normal file
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Bookmark;
|
||||
|
||||
use MintyPHP\Repository\User\BookmarkGroupRepositoryInterface;
|
||||
use MintyPHP\Repository\User\BookmarkNavigationRepositoryInterface;
|
||||
use MintyPHP\Repository\User\BookmarkRepositoryInterface;
|
||||
use MintyPHP\Service\Bookmark\BookmarkService;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BookmarkServiceTest extends TestCase
|
||||
{
|
||||
private BookmarkRepositoryInterface&MockObject $bookmarkRepo;
|
||||
private BookmarkGroupRepositoryInterface&MockObject $groupRepo;
|
||||
private BookmarkNavigationRepositoryInterface&MockObject $navigationRepo;
|
||||
private BookmarkService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->bookmarkRepo = $this->createMock(BookmarkRepositoryInterface::class);
|
||||
$this->groupRepo = $this->createMock(BookmarkGroupRepositoryInterface::class);
|
||||
$this->navigationRepo = $this->createMock(BookmarkNavigationRepositoryInterface::class);
|
||||
$this->service = new BookmarkService($this->bookmarkRepo, $this->groupRepo, $this->navigationRepo);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkHappyPath(): void
|
||||
{
|
||||
$this->bookmarkRepo->method('countByUser')->willReturn(0);
|
||||
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(1);
|
||||
$this->bookmarkRepo->method('create')->willReturn(42);
|
||||
|
||||
$result = $this->service->saveBookmark(1, 'Home', 'admin', null);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('created', $result['mode']);
|
||||
$this->assertSame(42, $result['bookmark']['id']);
|
||||
$this->assertSame('Home', $result['bookmark']['name']);
|
||||
$this->assertSame('admin', $result['bookmark']['url']);
|
||||
$this->assertNull($result['bookmark']['group_id']);
|
||||
$this->assertArrayNotHasKey('icon', $result['bookmark']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkUpsertsExistingBookmarkByExactUrl(): void
|
||||
{
|
||||
$this->bookmarkRepo->expects($this->once())->method('findByUserAndUrl')->with(1, 'admin/users?a=1&b=2')->willReturn([
|
||||
'id' => 9,
|
||||
'url' => 'admin/users?a=1&b=2',
|
||||
]);
|
||||
$this->bookmarkRepo->expects($this->once())->method('update')->with(
|
||||
9,
|
||||
1,
|
||||
[
|
||||
'name' => 'Users',
|
||||
'group_id' => null,
|
||||
]
|
||||
)->willReturn(true);
|
||||
$this->bookmarkRepo->expects($this->never())->method('create');
|
||||
|
||||
$result = $this->service->saveBookmark(1, 'Users', 'admin/users?b=2&a=1', null);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('updated', $result['mode']);
|
||||
$this->assertSame(9, $result['bookmark']['id']);
|
||||
$this->assertSame('admin/users?a=1&b=2', $result['bookmark']['url']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkUpsertsExistingBookmarkAndMovesToNewGroupAtEnd(): void
|
||||
{
|
||||
$this->groupRepo->expects($this->once())->method('findById')->with(3, 1)->willReturn([
|
||||
'id' => 3,
|
||||
'name' => 'Sales',
|
||||
]);
|
||||
$this->bookmarkRepo->expects($this->once())->method('findByUserAndUrl')->with(1, 'admin/users')->willReturn([
|
||||
'id' => 9,
|
||||
'url' => 'admin/users',
|
||||
'group_id' => 1,
|
||||
]);
|
||||
$this->bookmarkRepo->expects($this->once())->method('nextBookmarkSortOrder')->with(1, 3)->willReturn(12);
|
||||
$this->bookmarkRepo->expects($this->once())->method('update')->with(
|
||||
9,
|
||||
1,
|
||||
[
|
||||
'name' => 'Users',
|
||||
'group_id' => 3,
|
||||
'sort_order' => 12,
|
||||
]
|
||||
)->willReturn(true);
|
||||
$this->bookmarkRepo->expects($this->never())->method('create');
|
||||
|
||||
$result = $this->service->saveBookmark(1, 'Users', 'admin/users', 3);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('updated', $result['mode']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkUpsertsLegacyBookmarkByCanonicalUrl(): void
|
||||
{
|
||||
$this->bookmarkRepo->expects($this->once())->method('findByUserAndUrl')->with(1, 'admin/users?a=1&b=2')->willReturn(false);
|
||||
$this->bookmarkRepo->expects($this->once())->method('listByUser')->with(1)->willReturn([
|
||||
['id' => 7, 'url' => 'admin/users?b=2&a=1'],
|
||||
]);
|
||||
$this->bookmarkRepo->expects($this->once())->method('update')->with(
|
||||
7,
|
||||
1,
|
||||
[
|
||||
'name' => 'Users',
|
||||
'group_id' => null,
|
||||
'url' => 'admin/users?a=1&b=2',
|
||||
]
|
||||
)->willReturn(true);
|
||||
$this->bookmarkRepo->expects($this->never())->method('create');
|
||||
|
||||
$result = $this->service->saveBookmark(1, 'Users', 'admin/users?a=1&b=2', null);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('updated', $result['mode']);
|
||||
$this->assertSame(7, $result['bookmark']['id']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkRejectsEmptyName(): void
|
||||
{
|
||||
$result = $this->service->saveBookmark(1, '', 'admin', null);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('name_required', $result['error']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkRejectsEmptyUrl(): void
|
||||
{
|
||||
$result = $this->service->saveBookmark(1, 'Test', '', null);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('url_required', $result['error']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkRejectsAbsoluteUrl(): void
|
||||
{
|
||||
$result = $this->service->saveBookmark(1, 'External', 'https://evil.com', null);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('url_required', $result['error']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkRejectsProtocolRelativeUrl(): void
|
||||
{
|
||||
$result = $this->service->saveBookmark(1, 'External', '//evil.com', null);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('url_required', $result['error']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkMaxLimitReached(): void
|
||||
{
|
||||
$this->bookmarkRepo->method('countByUser')->willReturn(50);
|
||||
|
||||
$result = $this->service->saveBookmark(1, 'Test', 'admin', null);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('max_reached', $result['error']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkWithGroupValidatesOwnership(): void
|
||||
{
|
||||
$this->bookmarkRepo->method('countByUser')->willReturn(0);
|
||||
$this->groupRepo->expects($this->any())->method('findById')->with(5, 1)->willReturn(false);
|
||||
|
||||
$result = $this->service->saveBookmark(1, 'Test', 'admin', 5);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('group_not_found', $result['error']);
|
||||
}
|
||||
|
||||
public function testSaveBookmarkWithValidGroup(): void
|
||||
{
|
||||
$this->bookmarkRepo->method('countByUser')->willReturn(0);
|
||||
$this->bookmarkRepo->expects($this->once())->method('nextBookmarkSortOrder')->with(1, 5)->willReturn(7);
|
||||
$this->bookmarkRepo->expects($this->once())->method('create')->with([
|
||||
'user_id' => 1,
|
||||
'group_id' => 5,
|
||||
'name' => 'Test',
|
||||
'url' => 'admin',
|
||||
'sort_order' => 7,
|
||||
])->willReturn(10);
|
||||
$this->groupRepo->expects($this->any())->method('findById')->with(5, 1)->willReturn(['id' => 5, 'name' => 'Dev']);
|
||||
|
||||
$result = $this->service->saveBookmark(1, 'Test', 'admin', 5);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame(5, $result['bookmark']['group_id']);
|
||||
}
|
||||
|
||||
public function testUpdateBookmarkNotFound(): void
|
||||
{
|
||||
$this->bookmarkRepo->method('findById')->willReturn(false);
|
||||
|
||||
$result = $this->service->updateBookmark(1, 999, ['name' => 'New Name']);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('not_found', $result['error']);
|
||||
}
|
||||
|
||||
public function testUpdateBookmarkRejectsEmptyName(): void
|
||||
{
|
||||
$this->bookmarkRepo->method('findById')->willReturn(['id' => 1, 'name' => 'Old']);
|
||||
|
||||
$result = $this->service->updateBookmark(1, 1, ['name' => '']);
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('name_required', $result['error']);
|
||||
}
|
||||
|
||||
public function testUpdateBookmarkNoChanges(): void
|
||||
{
|
||||
$this->bookmarkRepo->method('findById')->willReturn(['id' => 1, 'name' => 'Old']);
|
||||
|
||||
$result = $this->service->updateBookmark(1, 1, []);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testUpdateBookmarkGroupChangeAppendsToTargetGroup(): void
|
||||
{
|
||||
$this->bookmarkRepo->expects($this->once())->method('findById')->with(5, 1)->willReturn([
|
||||
'id' => 5,
|
||||
'name' => 'Users',
|
||||
'group_id' => 1,
|
||||
]);
|
||||
$this->groupRepo->expects($this->once())->method('findById')->with(2, 1)->willReturn([
|
||||
'id' => 2,
|
||||
'name' => 'New',
|
||||
]);
|
||||
$this->bookmarkRepo->expects($this->once())->method('nextBookmarkSortOrder')->with(1, 2)->willReturn(9);
|
||||
$this->bookmarkRepo->expects($this->once())->method('update')->with(5, 1, [
|
||||
'group_id' => 2,
|
||||
'sort_order' => 9,
|
||||
])->willReturn(true);
|
||||
|
||||
$result = $this->service->updateBookmark(1, 5, ['group_id' => 2]);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testUpdateBookmarkMoveToUngroupedUsesRootSortOrder(): void
|
||||
{
|
||||
$this->bookmarkRepo->expects($this->once())->method('findById')->with(5, 1)->willReturn([
|
||||
'id' => 5,
|
||||
'name' => 'Users',
|
||||
'group_id' => 2,
|
||||
]);
|
||||
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(17);
|
||||
$this->bookmarkRepo->expects($this->once())->method('update')->with(5, 1, [
|
||||
'group_id' => null,
|
||||
'sort_order' => 17,
|
||||
])->willReturn(true);
|
||||
|
||||
$result = $this->service->updateBookmark(1, 5, ['group_id' => null]);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
}
|
||||
|
||||
public function testDeleteBookmark(): void
|
||||
{
|
||||
$this->bookmarkRepo->expects($this->any())->method('delete')->with(5, 1)->willReturn(true);
|
||||
|
||||
$this->assertTrue($this->service->deleteBookmark(1, 5));
|
||||
}
|
||||
|
||||
public function testSaveGroupHappyPath(): void
|
||||
{
|
||||
$this->groupRepo->method('countByUser')->willReturn(0);
|
||||
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(4);
|
||||
$this->groupRepo->expects($this->once())->method('create')->with([
|
||||
'user_id' => 1,
|
||||
'name' => 'Development',
|
||||
'icon' => 'bi-people',
|
||||
'sort_order' => 4,
|
||||
])->willReturn(3);
|
||||
|
||||
$result = $this->service->saveGroup(1, 'Development', 'bi-people');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('created', $result['mode']);
|
||||
$this->assertSame(3, $result['group']['id']);
|
||||
$this->assertSame('Development', $result['group']['name']);
|
||||
$this->assertSame('bi-people', $result['group']['icon']);
|
||||
}
|
||||
|
||||
public function testSaveGroupRejectsEmptyName(): void
|
||||
{
|
||||
$result = $this->service->saveGroup(1, '', 'bi-folder');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('name_required', $result['error']);
|
||||
}
|
||||
|
||||
public function testSaveGroupFallsBackToDefaultIcon(): void
|
||||
{
|
||||
$this->groupRepo->method('countByUser')->willReturn(0);
|
||||
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(1);
|
||||
$this->groupRepo->expects($this->once())->method('create')->with([
|
||||
'user_id' => 1,
|
||||
'name' => 'Development',
|
||||
'icon' => 'bi-folder',
|
||||
'sort_order' => 1,
|
||||
])->willReturn(11);
|
||||
|
||||
$result = $this->service->saveGroup(1, 'Development', 'invalid-icon');
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('bi-folder', $result['group']['icon']);
|
||||
}
|
||||
|
||||
public function testSaveGroupMaxLimitReached(): void
|
||||
{
|
||||
$this->groupRepo->method('countByUser')->willReturn(10);
|
||||
|
||||
$result = $this->service->saveGroup(1, 'New Group', 'bi-folder');
|
||||
|
||||
$this->assertFalse($result['ok']);
|
||||
$this->assertSame('max_reached', $result['error']);
|
||||
}
|
||||
|
||||
public function testSaveGroupRenameExisting(): void
|
||||
{
|
||||
$this->groupRepo->expects($this->any())->method('findById')->with(5, 1)->willReturn(['id' => 5, 'name' => 'Old']);
|
||||
$this->groupRepo->expects($this->once())
|
||||
->method('update')
|
||||
->with(5, 1, ['name' => 'Renamed', 'icon' => 'bi-heart'])
|
||||
->willReturn(true);
|
||||
|
||||
$result = $this->service->saveGroup(1, 'Renamed', 'bi-heart', 5);
|
||||
|
||||
$this->assertTrue($result['ok']);
|
||||
$this->assertSame('updated', $result['mode']);
|
||||
$this->assertSame(5, $result['group']['id']);
|
||||
$this->assertSame('Renamed', $result['group']['name']);
|
||||
$this->assertSame('bi-heart', $result['group']['icon']);
|
||||
}
|
||||
|
||||
public function testDeleteGroupClearsBookmarks(): void
|
||||
{
|
||||
$this->bookmarkRepo->expects($this->once())->method('clearGroupId')->with(5, 1)->willReturn(true);
|
||||
$this->groupRepo->expects($this->any())->method('delete')->with(5, 1)->willReturn(true);
|
||||
|
||||
$this->assertTrue($this->service->deleteGroup(1, 5));
|
||||
}
|
||||
|
||||
public function testDeleteGroupReturnsFalseWhenClearFails(): void
|
||||
{
|
||||
$this->bookmarkRepo->expects($this->once())->method('clearGroupId')->with(5, 1)->willReturn(false);
|
||||
$this->groupRepo->expects($this->never())->method('delete');
|
||||
|
||||
$this->assertFalse($this->service->deleteGroup(1, 5));
|
||||
}
|
||||
|
||||
public function testReorderGroupsDelegatesToRepository(): void
|
||||
{
|
||||
$pairs = [
|
||||
['id' => 4, 'sort_order' => 1],
|
||||
['id' => 6, 'sort_order' => 2],
|
||||
];
|
||||
$this->groupRepo->expects($this->once())->method('updateSortOrders')->with(1, $pairs)->willReturn(true);
|
||||
|
||||
$this->assertTrue($this->service->reorderGroups(1, $pairs));
|
||||
}
|
||||
|
||||
public function testReorderBookmarksReturnsFalseOnRepositoryFailure(): void
|
||||
{
|
||||
$pairs = [
|
||||
['id' => 11, 'sort_order' => 1],
|
||||
['id' => 7, 'sort_order' => 2],
|
||||
];
|
||||
$this->bookmarkRepo->expects($this->once())->method('updateSortOrders')->with(1, $pairs)->willReturn(false);
|
||||
|
||||
$this->assertFalse($this->service->reorderBookmarks(1, $pairs));
|
||||
}
|
||||
|
||||
public function testReorderRootDelegatesToNavigationRepository(): void
|
||||
{
|
||||
$pairs = [
|
||||
['kind' => 'bookmark', 'id' => 11, 'sort_order' => 1],
|
||||
['kind' => 'group', 'id' => 2, 'sort_order' => 2],
|
||||
];
|
||||
$this->navigationRepo->expects($this->once())
|
||||
->method('updateRootSortOrders')
|
||||
->with(1, $pairs)
|
||||
->willReturn(true);
|
||||
|
||||
$this->assertTrue($this->service->reorderRoot(1, $pairs));
|
||||
}
|
||||
|
||||
public function testListGroupedForUser(): void
|
||||
{
|
||||
$this->groupRepo->method('listByUser')->willReturn([
|
||||
['id' => 1, 'name' => 'Dev', 'icon' => 'bi-folder', 'sort_order' => 0],
|
||||
]);
|
||||
$this->bookmarkRepo->method('listByUser')->willReturn([
|
||||
['id' => 10, 'name' => 'Home', 'url' => 'admin', 'group_id' => null, 'sort_order' => 0],
|
||||
['id' => 11, 'name' => 'Users', 'url' => 'admin/users', 'group_id' => 1, 'sort_order' => 0],
|
||||
]);
|
||||
|
||||
$result = $this->service->listGroupedForUser(1);
|
||||
|
||||
$this->assertCount(1, $result['groups']);
|
||||
$this->assertSame('Dev', $result['groups'][0]['name']);
|
||||
$this->assertSame('bi-folder', $result['groups'][0]['icon']);
|
||||
$this->assertCount(1, $result['groups'][0]['bookmarks']);
|
||||
$this->assertSame('Users', $result['groups'][0]['bookmarks'][0]['name']);
|
||||
$this->assertArrayNotHasKey('icon', $result['groups'][0]['bookmarks'][0]);
|
||||
$this->assertCount(1, $result['ungrouped']);
|
||||
$this->assertSame('Home', $result['ungrouped'][0]['name']);
|
||||
$this->assertArrayNotHasKey('icon', $result['ungrouped'][0]);
|
||||
}
|
||||
|
||||
public function testAllowedGroupIconsReturnsNonEmptyList(): void
|
||||
{
|
||||
$icons = BookmarkService::allowedGroupIcons();
|
||||
$this->assertNotEmpty($icons);
|
||||
$this->assertContains('bi-folder', $icons);
|
||||
$this->assertContains('bi-house', $icons);
|
||||
}
|
||||
}
|
||||
51
tests/Support/BookmarkUrlNormalizerTest.php
Normal file
51
tests/Support/BookmarkUrlNormalizerTest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Support;
|
||||
|
||||
use MintyPHP\Support\BookmarkUrlNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BookmarkUrlNormalizerTest extends TestCase
|
||||
{
|
||||
public function testCanonicalizeRelativeNormalizesPathAndQueryOrder(): void
|
||||
{
|
||||
$actual = BookmarkUrlNormalizer::canonicalizeRelative('/admin//users/?b=2&a=1');
|
||||
|
||||
$this->assertSame('admin/users?a=1&b=2', $actual);
|
||||
}
|
||||
|
||||
public function testCanonicalizeRelativeSortsMultiValueQueryKeys(): void
|
||||
{
|
||||
$actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?roles=2&roles=1&search=max');
|
||||
|
||||
$this->assertSame('admin/users?roles=1&roles=2&search=max', $actual);
|
||||
}
|
||||
|
||||
public function testCanonicalizeRelativeRejectsAbsoluteUrl(): void
|
||||
{
|
||||
$actual = BookmarkUrlNormalizer::canonicalizeRelative('https://example.com/admin/users');
|
||||
|
||||
$this->assertSame('', $actual);
|
||||
}
|
||||
|
||||
public function testCanonicalizeRelativeRemovesFragment(): void
|
||||
{
|
||||
$actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?a=1#section-2');
|
||||
|
||||
$this->assertSame('admin/users?a=1', $actual);
|
||||
}
|
||||
|
||||
public function testCanonicalizeRequestUriStripsLocaleBaseAndSortsQuery(): void
|
||||
{
|
||||
$actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/de/admin/users?b=2&a=1', '/de/');
|
||||
|
||||
$this->assertSame('admin/users?a=1&b=2', $actual);
|
||||
}
|
||||
|
||||
public function testCanonicalizeRequestUriHandlesAppBaseAndLocale(): void
|
||||
{
|
||||
$actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/core/de/address-book?tenants=b&tenants=a', '/core/de/');
|
||||
|
||||
$this->assertSame('address-book?tenants=a&tenants=b', $actual);
|
||||
}
|
||||
}
|
||||
124
web/css/components/app-bookmark-form.css
Normal file
124
web/css/components/app-bookmark-form.css
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* app-bookmark-form.css
|
||||
* Styles for bookmark and bookmark-group dialogs.
|
||||
*/
|
||||
@layer components {
|
||||
dialog[data-app-bookmark-dialog] > article.app-bookmark-dialog,
|
||||
dialog[data-app-bookmark-group-dialog] > article.app-bookmark-dialog {
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
dialog[data-app-bookmark-dialog] > article.app-bookmark-dialog > header,
|
||||
dialog[data-app-bookmark-group-dialog] > article.app-bookmark-dialog > header {
|
||||
position: relative;
|
||||
padding-inline-end: 2rem;
|
||||
}
|
||||
|
||||
dialog[data-app-bookmark-dialog] > article.app-bookmark-dialog > header h2,
|
||||
dialog[data-app-bookmark-group-dialog] > article.app-bookmark-dialog > header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dialog[data-app-bookmark-dialog] [data-bookmark-dialog-close].close,
|
||||
dialog[data-app-bookmark-group-dialog] [data-bookmark-group-dialog-close].close {
|
||||
position: absolute;
|
||||
inset-inline-end: 1rem;
|
||||
inset-block-start: 1rem;
|
||||
float: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dialog[data-app-bookmark-dialog] form,
|
||||
dialog[data-app-bookmark-group-dialog] form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dialog[data-app-bookmark-dialog] form label,
|
||||
dialog[data-app-bookmark-group-dialog] form label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dialog[data-app-bookmark-dialog] form input[type="text"],
|
||||
dialog[data-app-bookmark-dialog] form select,
|
||||
dialog[data-app-bookmark-group-dialog] form input[type="text"],
|
||||
dialog[data-app-bookmark-group-dialog] form select {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dialog[data-app-bookmark-dialog] form footer,
|
||||
dialog[data-app-bookmark-group-dialog] form footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--app-spacing);
|
||||
}
|
||||
|
||||
dialog[data-app-bookmark-dialog] form footer button,
|
||||
dialog[data-app-bookmark-group-dialog] form footer button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-bookmark-delete-btn {
|
||||
color: var(--app-del-color, #c62828);
|
||||
border-color: var(--app-del-color, #c62828);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-bookmark-delete-btn:hover {
|
||||
background: var(--app-del-color, #c62828);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-bookmark-new-group-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-bookmark-group-icon-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.app-bookmark-group-icon-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 1.9rem;
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-border-radius);
|
||||
background: transparent;
|
||||
color: var(--app-muted-color);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
transition: border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.app-bookmark-group-icon-option:hover {
|
||||
border-color: var(--app-primary);
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.app-bookmark-group-icon-option.active {
|
||||
border-color: var(--app-primary);
|
||||
background: var(--app-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.app-bookmark-group-icon-option > i {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.app-bookmark-delete-separator {
|
||||
margin-block: 0.2rem 0.35rem;
|
||||
}
|
||||
}
|
||||
@@ -73,26 +73,24 @@
|
||||
}
|
||||
|
||||
.app-details-titlebar details.dropdown li {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border-bottom: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.app-details-titlebar details.dropdown li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.app-details-titlebar details.dropdown ul {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-details-titlebar details.dropdown li button,
|
||||
.app-details-titlebar details.dropdown li [role="button"] {
|
||||
margin: 0 !important;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.app-details-titlebar details.dropdown li button:hover,
|
||||
.app-details-titlebar details.dropdown li button:focus-visible,
|
||||
.app-details-titlebar details.dropdown li [role="button"]:hover,
|
||||
.app-details-titlebar details.dropdown li [role="button"]:focus-visible {
|
||||
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,26 +59,24 @@
|
||||
}
|
||||
|
||||
.app-list-titlebar details.dropdown li {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border-bottom: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.app-list-titlebar details.dropdown li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.app-list-titlebar details.dropdown ul {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-list-titlebar details.dropdown li button,
|
||||
.app-list-titlebar details.dropdown li [role="button"] {
|
||||
margin: 0 !important;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.app-list-titlebar details.dropdown li button:hover,
|
||||
.app-list-titlebar details.dropdown li button:focus-visible,
|
||||
.app-list-titlebar details.dropdown li [role="button"]:hover,
|
||||
.app-list-titlebar details.dropdown li [role="button"]:focus-visible {
|
||||
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1486,7 +1486,8 @@
|
||||
--app-background-color: var(--app-form-element-readonly-background-color);
|
||||
--app-border-color: var(--app-form-element-readonly-border-color);
|
||||
--app-color: var(--app-form-element-readonly-color);
|
||||
--app-box-shadow: inset 0 0 0 0.0625rem var(--app-form-element-readonly-border-color);
|
||||
--app-box-shadow: inset 0 0 0 0.0625rem
|
||||
var(--app-form-element-readonly-border-color);
|
||||
}
|
||||
|
||||
input:not(
|
||||
@@ -2396,8 +2397,8 @@
|
||||
min-width: fit-content;
|
||||
margin: 0;
|
||||
margin-top: var(--app-outline-width);
|
||||
padding: 0;
|
||||
border: var(--app-border-width) solid var(--app-dropdown-border-color);
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--app-dropdown-border-color);
|
||||
border-radius: var(--app-border-radius);
|
||||
background-color: var(--app-dropdown-background-color);
|
||||
box-shadow: var(--app-dropdown-box-shadow);
|
||||
@@ -2417,30 +2418,18 @@
|
||||
details.dropdown > summary + ul li {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
padding: calc(var(--app-form-element-spacing-vertical) * 0.5)
|
||||
var(--app-form-element-spacing-horizontal);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--app-accordion-border-color);
|
||||
}
|
||||
|
||||
details.dropdown > summary + ul li:first-of-type {
|
||||
margin-top: calc(var(--app-form-element-spacing-vertical) * 0.5);
|
||||
}
|
||||
|
||||
details.dropdown > summary + ul li:last-of-type {
|
||||
margin-bottom: calc(var(--app-form-element-spacing-vertical) * 0.5);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
details.dropdown > summary + ul li a {
|
||||
display: block;
|
||||
margin: calc(var(--app-form-element-spacing-vertical) * -0.5)
|
||||
calc(var(--app-form-element-spacing-horizontal) * -1);
|
||||
padding: calc(var(--app-form-element-spacing-vertical) * 0.5)
|
||||
var(--app-form-element-spacing-horizontal);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--app-dropdown-color);
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
@@ -2455,7 +2444,7 @@
|
||||
+ ul
|
||||
li
|
||||
a[aria-current]:not([aria-current="false"]) {
|
||||
background-color: var(--app-dropdown-hover-background-color);
|
||||
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
|
||||
}
|
||||
|
||||
details.dropdown > summary + ul li label {
|
||||
@@ -2463,7 +2452,8 @@
|
||||
}
|
||||
|
||||
details.dropdown > summary + ul li:has(label):hover {
|
||||
background-color: var(--app-dropdown-hover-background-color);
|
||||
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
details.dropdown[open] > summary {
|
||||
@@ -2779,6 +2769,10 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
dialog > article > footer button {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bi::before,
|
||||
[class^="bi-"]::before,
|
||||
[class*=" bi-"]::before {
|
||||
|
||||
@@ -283,68 +283,267 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.app-sidebar .app-sidebar-saved-filters {
|
||||
margin-bottom: var(--app-spacing);
|
||||
}
|
||||
|
||||
.app-sidebar .app-sidebar-saved-filter-item:before {
|
||||
content: "";
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-sidebar .app-sidebar-group.app-sidebar-saved-filters > small {
|
||||
padding: 5px var(--app-spacing);
|
||||
}
|
||||
|
||||
.app-sidebar .app-sidebar-saved-filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: space-between;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.app-sidebar .app-sidebar-saved-filter-item > a {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-sidebar .app-sidebar-saved-filter-delete {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-sidebar .app-sidebar-saved-filter-delete button {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--app-muted-color);
|
||||
padding: 4px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.app-sidebar .app-sidebar-saved-filter-delete button:hover {
|
||||
color: var(--app-contrast);
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
.app-sidebar-saved-filter-item:hover
|
||||
.app-sidebar-saved-filter-delete
|
||||
button,
|
||||
.app-sidebar
|
||||
.app-sidebar-saved-filter-item:focus-within
|
||||
.app-sidebar-saved-filter-delete
|
||||
button {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
aside.app-sidebar li.app-sidebar-group {
|
||||
margin-bottom: var(--app-spacing);
|
||||
}
|
||||
|
||||
.app-sidebar .app-sidebar-bookmark-item > a > i {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-left: var(--app-spacing);
|
||||
}
|
||||
|
||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--app-muted-color);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > i {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu {
|
||||
margin: 0;
|
||||
margin-right: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu
|
||||
summary:after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary {
|
||||
list-style: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--app-muted-color);
|
||||
padding: 0.25rem;
|
||||
min-width: 1.5rem;
|
||||
min-height: 1.5rem;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
background 120ms ease,
|
||||
opacity 120ms ease;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-item:hover
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary,
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-item:focus-within
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary,
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-group-header:hover
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary,
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-group-header:focus-within
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary,
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu[open]
|
||||
> summary,
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary
|
||||
i {
|
||||
font-size: 0.95rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu[open]
|
||||
> summary,
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary:hover,
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary:focus-visible {
|
||||
color: var(--app-contrast);
|
||||
background: color-mix(in srgb, var(--app-contrast) 10%, transparent);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu
|
||||
> summary {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list {
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
inset-block-start: calc(100% + 4px);
|
||||
min-width: 10rem;
|
||||
max-width: calc(220px - 2 * var(--app-spacing));
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: var(--app-border-radius);
|
||||
background: var(--app-card-bg, var(--app-background-color));
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.14),
|
||||
0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
padding: 0.25rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu-list
|
||||
> li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu-list
|
||||
button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: start;
|
||||
padding: 0.375rem 0.5rem;
|
||||
margin: 0;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu-list
|
||||
button:hover,
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu-list
|
||||
button:focus-visible {
|
||||
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu-list
|
||||
button[disabled] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu-list
|
||||
li:has(> .app-sidebar-bookmark-action-danger) {
|
||||
margin-top: 0.2rem;
|
||||
padding-top: 0.2rem;
|
||||
border-top: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu-list
|
||||
.app-sidebar-bookmark-action-danger {
|
||||
color: var(--app-del-color, #c62828);
|
||||
}
|
||||
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu-list
|
||||
.app-sidebar-bookmark-action-danger:hover,
|
||||
.app-sidebar
|
||||
#aside-panel-bookmarks
|
||||
.app-sidebar-bookmark-action-menu-list
|
||||
.app-sidebar-bookmark-action-danger:focus-visible {
|
||||
background: rgba(198, 40, 40, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,13 +187,12 @@ main:has(#app-details-aside-section) .app-topbar-menu-item-detail-sidebar {
|
||||
.app-topbar-menu-list li > a,
|
||||
.app-topbar-menu-list li > button {
|
||||
display: block;
|
||||
margin: calc(var(--app-form-element-spacing-vertical) * -0.5)
|
||||
calc(var(--app-form-element-spacing-horizontal) * -1);
|
||||
padding: calc(var(--app-form-element-spacing-vertical) * 0.5)
|
||||
var(--app-form-element-spacing-horizontal);
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.375rem 0.5rem;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--app-dropdown-color);
|
||||
font: inherit;
|
||||
@@ -213,7 +212,7 @@ main:has(#app-details-aside-section) .app-topbar-menu-item-detail-sidebar {
|
||||
.app-topbar-menu-list li > button:hover,
|
||||
.app-topbar-menu-list li > button:focus,
|
||||
.app-topbar-menu-list li > button:focus-visible {
|
||||
background-color: var(--app-dropdown-hover-background-color);
|
||||
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
|
||||
}
|
||||
|
||||
.app-topbar-menu-list li > button.app-topbar-menu-with-hotkey {
|
||||
@@ -230,9 +229,7 @@ main:has(#app-details-aside-section) .app-topbar-menu-item-detail-sidebar {
|
||||
}
|
||||
|
||||
.app-topbar-menu-list li.app-topbar-menu-heading {
|
||||
padding-top: calc(var(--app-spacing) * 0.5);
|
||||
padding-bottom: calc(var(--app-spacing) * 0.35);
|
||||
border-bottom: 0;
|
||||
padding: calc(var(--app-spacing) * 0.5) 0.5rem calc(var(--app-spacing) * 0.35);
|
||||
color: var(--app-muted-color);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
@@ -240,14 +237,9 @@ main:has(#app-details-aside-section) .app-topbar-menu-item-detail-sidebar {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-topbar-menu-list.app-topbar-menu-list--right li {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.app-topbar-menu-list li.app-topbar-menu-divider {
|
||||
margin: calc(var(--app-spacing) * 0.25) 0;
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
height: 1px;
|
||||
background: var(--app-border);
|
||||
list-style: none;
|
||||
|
||||
10
web/js/components/app-bookmark-init.js
Normal file
10
web/js/components/app-bookmark-init.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* app-bookmark-init.js
|
||||
* Initializes bookmark save form and sidebar panel.
|
||||
*/
|
||||
|
||||
import { init as initBookmarkSave } from './app-bookmark-save.js';
|
||||
import { init as initBookmarkPanel } from './app-bookmark-panel.js';
|
||||
|
||||
initBookmarkSave();
|
||||
initBookmarkPanel();
|
||||
508
web/js/components/app-bookmark-panel.js
Normal file
508
web/js/components/app-bookmark-panel.js
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Sidebar bookmarks panel actions:
|
||||
* - Reorder root navigation (groups + ungrouped bookmarks) via up/down
|
||||
* - Reorder bookmarks inside groups (up/down)
|
||||
* - Edit/Delete groups and bookmarks with confirm dialog
|
||||
*/
|
||||
import { showAsyncFlash } from './app-async-flash.js';
|
||||
import { warnOnce } from '../core/app-dom.js';
|
||||
import { confirmDialog } from '../core/app-confirm-dialog.js';
|
||||
|
||||
export function init() {
|
||||
const panel = document.querySelector('[data-aside-panel="bookmarks"]');
|
||||
if (!(panel instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderUrl = String(panel.dataset.bookmarkReorderUrl || '').trim();
|
||||
const groupDeleteUrl = String(panel.dataset.bookmarkGroupDeleteUrl || '').trim();
|
||||
const bookmarkDeleteUrl = String(panel.dataset.bookmarkDeleteUrl || '').trim();
|
||||
if (!reorderUrl || !groupDeleteUrl || !bookmarkDeleteUrl) {
|
||||
warnOnce('UI_DATA_MISSING', 'Missing bookmark panel endpoint metadata', { module: 'bookmark-panel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const csrfKey = String(root.dataset.csrfKey || '').trim();
|
||||
const csrfToken = String(root.dataset.csrfToken || '').trim();
|
||||
if (!csrfKey || !csrfToken) {
|
||||
warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark panel', { module: 'bookmark-panel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const messageDeleteConfirm = panel.dataset.bookmarkMsgGroupDeleteConfirm || 'Delete group and keep bookmarks?';
|
||||
const messageGroupDeleted = panel.dataset.bookmarkMsgGroupDeleted || 'Group deleted';
|
||||
const messageGroupDeleteFailed = panel.dataset.bookmarkMsgGroupDeleteFailed || 'Group delete failed';
|
||||
const messageBookmarkDeleteConfirm = panel.dataset.bookmarkMsgBookmarkDeleteConfirm || 'Delete this bookmark?';
|
||||
const messageBookmarkDeleted = panel.dataset.bookmarkMsgBookmarkDeleted || 'Bookmark deleted';
|
||||
const messageBookmarkDeleteFailed = panel.dataset.bookmarkMsgBookmarkDeleteFailed || 'Bookmark action failed';
|
||||
const messageGroupActionFailed = panel.dataset.bookmarkMsgGroupActionFailed || messageGroupDeleteFailed;
|
||||
const messageBookmarkActionFailed = panel.dataset.bookmarkMsgBookmarkActionFailed || messageBookmarkDeleteFailed;
|
||||
const messageReorderFailed = panel.dataset.bookmarkMsgReorderFailed || 'Reorder failed';
|
||||
const labelDelete = panel.dataset.bookmarkLabelDelete || 'Delete';
|
||||
|
||||
const moduleContext = { module: 'bookmark-panel' };
|
||||
|
||||
panel.querySelectorAll('[data-bookmark-item-action], [data-bookmark-group-action]').forEach((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
button.dataset.baseDisabled = button.disabled ? '1' : '0';
|
||||
});
|
||||
|
||||
let pending = false;
|
||||
|
||||
const actionMenus = () => Array.from(panel.querySelectorAll('[data-bookmark-action-menu]'))
|
||||
.filter((menu) => menu instanceof HTMLDetailsElement);
|
||||
|
||||
const closeActionMenus = (except = null) => {
|
||||
actionMenus().forEach((menu) => {
|
||||
if (menu === except) {
|
||||
return;
|
||||
}
|
||||
menu.open = false;
|
||||
});
|
||||
};
|
||||
|
||||
const panelActionButtons = () => Array.from(
|
||||
panel.querySelectorAll('[data-bookmark-item-action], [data-bookmark-group-action]')
|
||||
).filter((button) => button instanceof HTMLButtonElement);
|
||||
|
||||
const setPending = (nextPending) => {
|
||||
pending = nextPending;
|
||||
panelActionButtons().forEach((button) => {
|
||||
const baselineDisabled = button.dataset.baseDisabled === '1';
|
||||
button.disabled = nextPending || baselineDisabled;
|
||||
});
|
||||
};
|
||||
|
||||
const parseJsonSafe = async (response) => {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const reportError = (code, message, details = {}) => {
|
||||
warnOnce(code, message, { ...moduleContext, ...details });
|
||||
showAsyncFlash('error', message);
|
||||
};
|
||||
|
||||
const runReload = (successMessage = '') => {
|
||||
if (String(successMessage).trim() !== '') {
|
||||
showAsyncFlash('success', successMessage, 2500);
|
||||
}
|
||||
window.setTimeout(() => window.location.reload(), 450);
|
||||
};
|
||||
|
||||
const directChildren = (container, className) => {
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(container.children).filter(
|
||||
(child) => child instanceof HTMLElement && child.classList.contains(className)
|
||||
);
|
||||
};
|
||||
|
||||
const swapInList = (items, currentItem, direction) => {
|
||||
const currentIndex = items.indexOf(currentItem);
|
||||
if (currentIndex < 0) {
|
||||
return [];
|
||||
}
|
||||
const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (nextIndex < 0 || nextIndex >= items.length) {
|
||||
return [];
|
||||
}
|
||||
const reordered = items.slice();
|
||||
const temp = reordered[currentIndex];
|
||||
reordered[currentIndex] = reordered[nextIndex];
|
||||
reordered[nextIndex] = temp;
|
||||
return reordered;
|
||||
};
|
||||
|
||||
const buildPairs = (items, dataKey) => {
|
||||
const pairs = [];
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
const id = Number.parseInt(item.dataset[dataKey] || '', 10);
|
||||
if (id <= 0) {
|
||||
return [];
|
||||
}
|
||||
pairs.push({
|
||||
id,
|
||||
sort_order: index + 1
|
||||
});
|
||||
}
|
||||
return pairs;
|
||||
};
|
||||
|
||||
const buildRootPairs = (items) => {
|
||||
const pairs = [];
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
const kind = String(item.dataset.bookmarkRootKind || '').trim();
|
||||
const id = Number.parseInt(item.dataset.bookmarkRootId || '', 10);
|
||||
if ((kind !== 'group' && kind !== 'bookmark') || id <= 0) {
|
||||
return [];
|
||||
}
|
||||
pairs.push({
|
||||
kind,
|
||||
id,
|
||||
sort_order: index + 1
|
||||
});
|
||||
}
|
||||
return pairs;
|
||||
};
|
||||
|
||||
const submitReorder = async (type, pairs) => {
|
||||
if (pairs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.set(csrfKey, csrfToken);
|
||||
body.set('type', type);
|
||||
body.set('items', JSON.stringify(pairs));
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await fetch(reorderUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body
|
||||
});
|
||||
const json = await parseJsonSafe(response);
|
||||
if (!response.ok || !json) {
|
||||
reportError('FETCH_FAILED', messageReorderFailed, { status: response.status, type });
|
||||
return;
|
||||
}
|
||||
if (!json.ok) {
|
||||
reportError('FETCH_FAILED', messageReorderFailed, { error: json.error, type });
|
||||
return;
|
||||
}
|
||||
|
||||
runReload();
|
||||
} catch (error) {
|
||||
reportError('FETCH_ERROR', messageReorderFailed, { error, type });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reorderGroup = async (button) => {
|
||||
const groupItem = button.closest('.app-sidebar-bookmark-root-item.app-sidebar-bookmark-group');
|
||||
if (!(groupItem instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const container = groupItem.parentElement;
|
||||
const rootItems = directChildren(container, 'app-sidebar-bookmark-root-item');
|
||||
if (rootItems.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = String(button.dataset.bookmarkGroupAction || '').trim();
|
||||
const direction = action === 'move-up' ? 'up' : action === 'move-down' ? 'down' : '';
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderedRootItems = swapInList(rootItems, groupItem, direction);
|
||||
if (reorderedRootItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pairs = buildRootPairs(reorderedRootItems);
|
||||
if (pairs.length !== reorderedRootItems.length) {
|
||||
reportError('UI_INVALID_STATE', messageReorderFailed, { action, target: 'root' });
|
||||
return;
|
||||
}
|
||||
|
||||
await submitReorder('root', pairs);
|
||||
};
|
||||
|
||||
const reorderBookmark = async (button) => {
|
||||
const bookmarkItem = button.closest('.app-sidebar-bookmark-item');
|
||||
if (!(bookmarkItem instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = String(button.dataset.bookmarkItemAction || '').trim();
|
||||
const direction = action === 'move-up' ? 'up' : action === 'move-down' ? 'down' : '';
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = String(button.dataset.bookmarkItemActionScope || '').trim();
|
||||
if (scope === 'root') {
|
||||
const rootItem = bookmarkItem.closest('.app-sidebar-bookmark-root-item');
|
||||
if (!(rootItem instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const container = rootItem.parentElement;
|
||||
const rootItems = directChildren(container, 'app-sidebar-bookmark-root-item');
|
||||
if (rootItems.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderedRootItems = swapInList(rootItems, rootItem, direction);
|
||||
if (reorderedRootItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootPairs = buildRootPairs(reorderedRootItems);
|
||||
if (rootPairs.length !== reorderedRootItems.length) {
|
||||
reportError('UI_INVALID_STATE', messageReorderFailed, { action, target: 'root' });
|
||||
return;
|
||||
}
|
||||
|
||||
await submitReorder('root', rootPairs);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = bookmarkItem.parentElement;
|
||||
const bookmarks = directChildren(container, 'app-sidebar-bookmark-item');
|
||||
if (bookmarks.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reorderedBookmarks = swapInList(bookmarks, bookmarkItem, direction);
|
||||
if (reorderedBookmarks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pairs = buildPairs(reorderedBookmarks, 'bookmarkId');
|
||||
if (pairs.length !== reorderedBookmarks.length) {
|
||||
reportError('UI_INVALID_STATE', messageReorderFailed, { action, target: 'bookmarks' });
|
||||
return;
|
||||
}
|
||||
|
||||
await submitReorder('bookmarks', pairs);
|
||||
};
|
||||
|
||||
const deleteGroup = async (button) => {
|
||||
const groupItem = button.closest('.app-sidebar-bookmark-group');
|
||||
if (!(groupItem instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const groupId = Number.parseInt(groupItem.dataset.bookmarkGroupId || '', 10);
|
||||
if (groupId <= 0) {
|
||||
reportError('UI_INVALID_STATE', messageGroupDeleteFailed, { reason: 'invalid_group_id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const approved = await confirmDialog.confirm({
|
||||
message: messageDeleteConfirm,
|
||||
confirmLabel: labelDelete,
|
||||
actionKind: 'delete',
|
||||
variant: 'danger',
|
||||
focus: 'cancel'
|
||||
});
|
||||
if (!approved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.set(csrfKey, csrfToken);
|
||||
body.set('id', String(groupId));
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await fetch(groupDeleteUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body
|
||||
});
|
||||
const json = await parseJsonSafe(response);
|
||||
if (!response.ok || !json) {
|
||||
reportError('FETCH_FAILED', messageGroupDeleteFailed, { status: response.status, groupId });
|
||||
return;
|
||||
}
|
||||
if (!json.ok) {
|
||||
reportError('FETCH_FAILED', messageGroupDeleteFailed, { error: json.error, groupId });
|
||||
return;
|
||||
}
|
||||
|
||||
runReload(messageGroupDeleted);
|
||||
} catch (error) {
|
||||
reportError('FETCH_ERROR', messageGroupDeleteFailed, { error, groupId });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBookmark = async (button) => {
|
||||
const bookmarkItem = button.closest('.app-sidebar-bookmark-item');
|
||||
if (!(bookmarkItem instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const bookmarkId = Number.parseInt(bookmarkItem.dataset.bookmarkId || '', 10);
|
||||
if (bookmarkId <= 0) {
|
||||
reportError('UI_INVALID_STATE', messageBookmarkDeleteFailed, { reason: 'invalid_bookmark_id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const approved = await confirmDialog.confirm({
|
||||
message: messageBookmarkDeleteConfirm,
|
||||
confirmLabel: labelDelete,
|
||||
actionKind: 'delete',
|
||||
variant: 'danger',
|
||||
focus: 'cancel'
|
||||
});
|
||||
if (!approved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.set(csrfKey, csrfToken);
|
||||
body.set('id', String(bookmarkId));
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await fetch(bookmarkDeleteUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body
|
||||
});
|
||||
const json = await parseJsonSafe(response);
|
||||
if (!response.ok || !json) {
|
||||
reportError('FETCH_FAILED', messageBookmarkDeleteFailed, { status: response.status, bookmarkId });
|
||||
return;
|
||||
}
|
||||
if (!json.ok) {
|
||||
reportError('FETCH_FAILED', messageBookmarkDeleteFailed, { error: json.error, bookmarkId });
|
||||
return;
|
||||
}
|
||||
|
||||
runReload(messageBookmarkDeleted);
|
||||
} catch (error) {
|
||||
reportError('FETCH_ERROR', messageBookmarkDeleteFailed, { error, bookmarkId });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editGroup = (button) => {
|
||||
const groupItem = button.closest('.app-sidebar-bookmark-group');
|
||||
if (!(groupItem instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const groupId = Number.parseInt(groupItem.dataset.bookmarkGroupId || '', 10);
|
||||
const groupName = String(groupItem.dataset.bookmarkGroupName || '').trim();
|
||||
const groupIcon = String(groupItem.dataset.bookmarkGroupIcon || 'bi-folder').trim() || 'bi-folder';
|
||||
if (groupId <= 0 || !groupName) {
|
||||
reportError('UI_INVALID_STATE', messageGroupActionFailed, { reason: 'invalid_group_payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('app:bookmark-group-edit', {
|
||||
detail: {
|
||||
id: groupId,
|
||||
name: groupName,
|
||||
icon: groupIcon
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const editBookmark = (button) => {
|
||||
const bookmarkItem = button.closest('.app-sidebar-bookmark-item');
|
||||
if (!(bookmarkItem instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bookmarkId = Number.parseInt(bookmarkItem.dataset.bookmarkId || '', 10);
|
||||
const bookmarkName = String(bookmarkItem.dataset.bookmarkName || '').trim();
|
||||
const groupId = Number.parseInt(bookmarkItem.dataset.bookmarkGroupId || '', 10);
|
||||
if (bookmarkId <= 0 || !bookmarkName) {
|
||||
reportError('UI_INVALID_STATE', messageBookmarkActionFailed, { reason: 'invalid_bookmark_payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent('app:bookmark-edit', {
|
||||
detail: {
|
||||
id: bookmarkId,
|
||||
name: bookmarkName,
|
||||
groupId: groupId > 0 ? String(groupId) : ''
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
panel.addEventListener('click', (event) => {
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuSummary = target.closest('[data-bookmark-action-menu] > summary');
|
||||
if (menuSummary instanceof HTMLElement) {
|
||||
if (pending) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
const menu = menuSummary.parentElement;
|
||||
if (menu instanceof HTMLDetailsElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const shouldOpen = !menu.open;
|
||||
closeActionMenus(menu);
|
||||
menu.open = shouldOpen;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupActionButton = target.closest('[data-bookmark-group-action]');
|
||||
if (groupActionButton instanceof HTMLButtonElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeActionMenus();
|
||||
|
||||
const action = String(groupActionButton.dataset.bookmarkGroupAction || '').trim();
|
||||
if (action === 'edit') {
|
||||
editGroup(groupActionButton);
|
||||
return;
|
||||
}
|
||||
if (action === 'delete') {
|
||||
void deleteGroup(groupActionButton);
|
||||
return;
|
||||
}
|
||||
if (action === 'move-up' || action === 'move-down') {
|
||||
void reorderGroup(groupActionButton);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const itemActionButton = target.closest('[data-bookmark-item-action]');
|
||||
if (itemActionButton instanceof HTMLButtonElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeActionMenus();
|
||||
const action = String(itemActionButton.dataset.bookmarkItemAction || '').trim();
|
||||
if (action === 'edit') {
|
||||
editBookmark(itemActionButton);
|
||||
return;
|
||||
}
|
||||
if (action === 'delete') {
|
||||
void deleteBookmark(itemActionButton);
|
||||
return;
|
||||
}
|
||||
if (action === 'move-up' || action === 'move-down') {
|
||||
void reorderBookmark(itemActionButton);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!(event.target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
const clickedInsideMenu = actionMenus().some((menu) => menu.contains(event.target));
|
||||
if (!clickedInsideMenu) {
|
||||
closeActionMenus();
|
||||
}
|
||||
});
|
||||
}
|
||||
593
web/js/components/app-bookmark-save.js
Normal file
593
web/js/components/app-bookmark-save.js
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* app-bookmark-save.js
|
||||
* Bookmark dialog (create/update) + group dialog (create/update).
|
||||
*/
|
||||
|
||||
import { showAsyncFlash } from './app-async-flash.js';
|
||||
import { warnOnce } from '../core/app-dom.js';
|
||||
|
||||
export function init() {
|
||||
const openBtn = document.querySelector('[data-bookmark-open-dialog]');
|
||||
const bookmarkDialog = document.querySelector('[data-app-bookmark-dialog]');
|
||||
if (!(bookmarkDialog instanceof HTMLDialogElement) || !(openBtn instanceof HTMLButtonElement)) return;
|
||||
|
||||
const bookmarkForm = bookmarkDialog.querySelector('[data-bookmark-save-form]');
|
||||
if (!(bookmarkForm instanceof HTMLFormElement)) return;
|
||||
|
||||
const groupDialog = document.querySelector('[data-app-bookmark-group-dialog]');
|
||||
const groupForm = groupDialog instanceof HTMLDialogElement
|
||||
? groupDialog.querySelector('[data-bookmark-group-save-form]')
|
||||
: null;
|
||||
const hasGroupDialog = groupDialog instanceof HTMLDialogElement && groupForm instanceof HTMLFormElement;
|
||||
|
||||
const root = document.documentElement;
|
||||
const csrfKey = String(root.dataset.csrfKey || '').trim();
|
||||
const csrfToken = String(root.dataset.csrfToken || '').trim();
|
||||
const moduleContext = { module: 'bookmark-save' };
|
||||
|
||||
if (!csrfKey || !csrfToken) {
|
||||
warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark dialog', moduleContext);
|
||||
return;
|
||||
}
|
||||
|
||||
const nameInput = bookmarkForm.querySelector('input[name="name"]');
|
||||
const groupSelect = bookmarkForm.querySelector('[data-bookmark-group-select]');
|
||||
const newGroupBtn = bookmarkForm.querySelector('[data-bookmark-new-group]');
|
||||
const closeBookmarkBtns = bookmarkDialog.querySelectorAll('[data-bookmark-dialog-close]');
|
||||
const submitBtn = bookmarkDialog.querySelector('[data-bookmark-submit-label]');
|
||||
const titleEl = bookmarkDialog.querySelector('#app-bookmark-dialog-title');
|
||||
|
||||
const maxBookmarkMessage = bookmarkDialog.dataset.maxMessage || '';
|
||||
const bookmarkMessageSaved = bookmarkDialog.dataset.msgSaved || 'Bookmark saved';
|
||||
const bookmarkMessageUpdated = bookmarkDialog.dataset.msgUpdated || 'Bookmark updated';
|
||||
const bookmarkMessageErrorGeneric = bookmarkDialog.dataset.msgErrorGeneric || 'Bookmark action failed';
|
||||
const bookmarkMessageNameRequired = bookmarkDialog.dataset.msgNameRequired || 'Bookmark name is required';
|
||||
const bookmarkApiErrorMessages = {
|
||||
name_required: bookmarkDialog.dataset.msgNameRequired || bookmarkMessageErrorGeneric,
|
||||
url_required: bookmarkDialog.dataset.msgUrlRequired || bookmarkMessageErrorGeneric,
|
||||
group_not_found: bookmarkDialog.dataset.msgGroupNotFound || bookmarkMessageErrorGeneric,
|
||||
not_found: bookmarkMessageErrorGeneric,
|
||||
create_failed: bookmarkMessageErrorGeneric,
|
||||
update_failed: bookmarkMessageErrorGeneric
|
||||
};
|
||||
|
||||
let editingBookmarkId = null;
|
||||
let bookmarkReturnState = null;
|
||||
|
||||
let groupEditingId = null;
|
||||
let groupMode = 'create';
|
||||
let groupSource = 'sidebar';
|
||||
|
||||
const syncModalBodyClass = () => {
|
||||
const isOpen = bookmarkDialog.open || (hasGroupDialog && groupDialog.open);
|
||||
document.body.classList.toggle('modal-is-open', isOpen);
|
||||
};
|
||||
|
||||
const reportError = (code, message, details = {}) => {
|
||||
warnOnce(code, message, { ...moduleContext, ...details });
|
||||
showAsyncFlash('error', message);
|
||||
};
|
||||
|
||||
const parseJsonSafe = async (response) => {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveBookmarkApiErrorMessage = (errorCode) => {
|
||||
if (!errorCode) return bookmarkMessageErrorGeneric;
|
||||
return bookmarkApiErrorMessages[errorCode] || bookmarkMessageErrorGeneric;
|
||||
};
|
||||
|
||||
const runReloadWithSuccess = (message) => {
|
||||
showAsyncFlash('success', message, 2500);
|
||||
window.setTimeout(() => window.location.reload(), 450);
|
||||
};
|
||||
|
||||
const closeBookmarkDialog = () => {
|
||||
try {
|
||||
if (bookmarkDialog.open) bookmarkDialog.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
bookmarkForm.removeAttribute('aria-busy');
|
||||
syncModalBodyClass();
|
||||
};
|
||||
|
||||
const openBookmarkDialog = () => {
|
||||
try {
|
||||
bookmarkDialog.showModal();
|
||||
syncModalBodyClass();
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError('UI_DIALOG_OPEN_FAILED', bookmarkMessageErrorGeneric, { error });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const setBookmarkSubmitPending = (pending) => {
|
||||
if (submitBtn instanceof HTMLButtonElement) {
|
||||
submitBtn.disabled = pending;
|
||||
}
|
||||
bookmarkForm.setAttribute('aria-busy', pending ? 'true' : 'false');
|
||||
};
|
||||
|
||||
const setBookmarkSubmitLabel = (label) => {
|
||||
if (!(submitBtn instanceof HTMLElement)) return;
|
||||
const text = String(label || '').trim();
|
||||
if (!text) return;
|
||||
submitBtn.textContent = text;
|
||||
};
|
||||
|
||||
const applyBookmarkModeUi = (isEditMode) => {
|
||||
if (titleEl) {
|
||||
titleEl.textContent = isEditMode
|
||||
? (bookmarkDialog.dataset.titleEdit || 'Bookmarks')
|
||||
: (bookmarkDialog.dataset.titleCreate || 'Bookmarks');
|
||||
}
|
||||
setBookmarkSubmitLabel(isEditMode
|
||||
? (bookmarkDialog.dataset.labelUpdate || 'Save')
|
||||
: (bookmarkDialog.dataset.labelSave || 'Save'));
|
||||
};
|
||||
|
||||
const configureBookmarkModeFromTopbar = () => {
|
||||
const isEditMode = openBtn.hasAttribute('data-bookmark-existing-id');
|
||||
applyBookmarkModeUi(isEditMode);
|
||||
|
||||
if (isEditMode) {
|
||||
editingBookmarkId = openBtn.dataset.bookmarkExistingId || null;
|
||||
if (nameInput) nameInput.value = openBtn.dataset.bookmarkExistingName || '';
|
||||
if (groupSelect) groupSelect.value = openBtn.dataset.bookmarkExistingGroup || '';
|
||||
return;
|
||||
}
|
||||
|
||||
editingBookmarkId = null;
|
||||
bookmarkForm.reset();
|
||||
if (nameInput) {
|
||||
const title = document.title.replace(/\s*[|–—].*$/, '').trim();
|
||||
nameInput.value = title || '';
|
||||
}
|
||||
};
|
||||
|
||||
const openBookmarkEditFromSidebar = (detail) => {
|
||||
const bookmarkId = Number.parseInt(String(detail?.id ?? ''), 10);
|
||||
const bookmarkName = String(detail?.name ?? '').trim();
|
||||
const groupId = String(detail?.groupId ?? '').trim();
|
||||
if (bookmarkId <= 0 || !bookmarkName) {
|
||||
reportError('UI_INVALID_STATE', bookmarkMessageErrorGeneric, { detail });
|
||||
return;
|
||||
}
|
||||
|
||||
editingBookmarkId = String(bookmarkId);
|
||||
applyBookmarkModeUi(true);
|
||||
if (nameInput instanceof HTMLInputElement) {
|
||||
nameInput.value = bookmarkName;
|
||||
}
|
||||
if (groupSelect instanceof HTMLSelectElement) {
|
||||
groupSelect.value = groupId;
|
||||
}
|
||||
|
||||
if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}
|
||||
};
|
||||
|
||||
const captureBookmarkState = () => ({
|
||||
isEditMode: editingBookmarkId !== null && editingBookmarkId !== '',
|
||||
bookmarkId: editingBookmarkId,
|
||||
name: nameInput instanceof HTMLInputElement ? nameInput.value : '',
|
||||
groupId: groupSelect instanceof HTMLSelectElement ? groupSelect.value : ''
|
||||
});
|
||||
|
||||
const restoreBookmarkFromState = (selectedGroupId = null) => {
|
||||
const state = bookmarkReturnState;
|
||||
bookmarkReturnState = null;
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyBookmarkModeUi(Boolean(state.isEditMode));
|
||||
editingBookmarkId = state.isEditMode ? state.bookmarkId : null;
|
||||
if (nameInput instanceof HTMLInputElement) {
|
||||
nameInput.value = state.name || '';
|
||||
}
|
||||
if (groupSelect instanceof HTMLSelectElement) {
|
||||
groupSelect.value = selectedGroupId ?? state.groupId ?? '';
|
||||
}
|
||||
|
||||
if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}
|
||||
};
|
||||
|
||||
// Bookmark dialog open
|
||||
openBtn.addEventListener('click', () => {
|
||||
configureBookmarkModeFromTopbar();
|
||||
if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) {
|
||||
nameInput.select();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('app:bookmark-edit', (event) => {
|
||||
const detail = event instanceof CustomEvent ? event.detail : null;
|
||||
openBookmarkEditFromSidebar(detail);
|
||||
});
|
||||
|
||||
closeBookmarkBtns.forEach((btn) => {
|
||||
btn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
closeBookmarkDialog();
|
||||
});
|
||||
});
|
||||
|
||||
bookmarkDialog.addEventListener('click', (event) => {
|
||||
if (event.target === bookmarkDialog) {
|
||||
closeBookmarkDialog();
|
||||
}
|
||||
});
|
||||
bookmarkDialog.addEventListener('close', () => {
|
||||
bookmarkForm.removeAttribute('aria-busy');
|
||||
syncModalBodyClass();
|
||||
});
|
||||
bookmarkDialog.addEventListener('cancel', () => {
|
||||
bookmarkForm.removeAttribute('aria-busy');
|
||||
syncModalBodyClass();
|
||||
});
|
||||
|
||||
// Save bookmark
|
||||
bookmarkForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const name = nameInput instanceof HTMLInputElement ? nameInput.value.trim() : '';
|
||||
if (!name) {
|
||||
showAsyncFlash('warning', bookmarkMessageNameRequired);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.set(csrfKey, csrfToken);
|
||||
body.set('name', name);
|
||||
body.set('group_id', groupSelect instanceof HTMLSelectElement ? groupSelect.value : '');
|
||||
|
||||
setBookmarkSubmitPending(true);
|
||||
if (editingBookmarkId) {
|
||||
body.set('id', editingBookmarkId);
|
||||
try {
|
||||
const response = await fetch(getAppBase() + 'bookmarks/update-data', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body
|
||||
});
|
||||
const json = await parseJsonSafe(response);
|
||||
if (!response.ok || !json) {
|
||||
reportError('FETCH_FAILED', bookmarkMessageErrorGeneric, { status: response.status });
|
||||
return;
|
||||
}
|
||||
if (!json.ok) {
|
||||
reportError('FETCH_FAILED', resolveBookmarkApiErrorMessage(json.error), { error: json.error });
|
||||
return;
|
||||
}
|
||||
|
||||
closeBookmarkDialog();
|
||||
runReloadWithSuccess(bookmarkMessageUpdated);
|
||||
} catch (error) {
|
||||
reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error });
|
||||
} finally {
|
||||
setBookmarkSubmitPending(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
body.set('url', currentRelativeUrl());
|
||||
try {
|
||||
const response = await fetch(getAppBase() + 'bookmarks/save-data', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body
|
||||
});
|
||||
const json = await parseJsonSafe(response);
|
||||
if (!response.ok || !json) {
|
||||
reportError('FETCH_FAILED', bookmarkMessageErrorGeneric, { status: response.status });
|
||||
return;
|
||||
}
|
||||
if (!json.ok) {
|
||||
if (json.error === 'max_reached') {
|
||||
showAsyncFlash('warning', maxBookmarkMessage || 'Maximum bookmarks reached');
|
||||
return;
|
||||
}
|
||||
reportError('FETCH_FAILED', resolveBookmarkApiErrorMessage(json.error), { error: json.error });
|
||||
return;
|
||||
}
|
||||
|
||||
closeBookmarkDialog();
|
||||
runReloadWithSuccess(json.mode === 'updated' ? bookmarkMessageUpdated : bookmarkMessageSaved);
|
||||
} catch (error) {
|
||||
reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error });
|
||||
} finally {
|
||||
setBookmarkSubmitPending(false);
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasGroupDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupNameInput = groupForm.querySelector('input[name="name"]');
|
||||
const groupIconInput = groupForm.querySelector('input[name="icon"]');
|
||||
const groupIconPicker = groupForm.querySelector('[data-bookmark-group-icon-picker]');
|
||||
const groupCloseBtns = groupDialog.querySelectorAll('[data-bookmark-group-dialog-close]');
|
||||
const groupTitleEl = groupDialog.querySelector('#app-bookmark-group-dialog-title');
|
||||
const groupSubmitBtn = groupDialog.querySelector('[data-bookmark-group-submit-label]');
|
||||
|
||||
const groupMessageCreated = groupDialog.dataset.msgCreated || 'Group created';
|
||||
const groupMessageUpdated = groupDialog.dataset.msgUpdated || 'Group updated';
|
||||
const groupMessageErrorGeneric = groupDialog.dataset.msgErrorGeneric || 'Group action failed';
|
||||
const groupMessageNameRequired = groupDialog.dataset.msgNameRequired || 'Group name is required';
|
||||
const maxGroupMessage = groupDialog.dataset.maxMessage || 'Maximum groups reached';
|
||||
const groupApiErrorMessages = {
|
||||
name_required: groupMessageNameRequired,
|
||||
not_found: groupMessageErrorGeneric,
|
||||
create_failed: groupMessageErrorGeneric,
|
||||
update_failed: groupMessageErrorGeneric
|
||||
};
|
||||
|
||||
const resolveGroupApiErrorMessage = (errorCode) => {
|
||||
if (!errorCode) return groupMessageErrorGeneric;
|
||||
return groupApiErrorMessages[errorCode] || groupMessageErrorGeneric;
|
||||
};
|
||||
|
||||
const closeGroupDialog = () => {
|
||||
try {
|
||||
if (groupDialog.open) groupDialog.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
groupForm.removeAttribute('aria-busy');
|
||||
syncModalBodyClass();
|
||||
};
|
||||
|
||||
const openGroupDialog = () => {
|
||||
try {
|
||||
groupDialog.showModal();
|
||||
syncModalBodyClass();
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError('UI_DIALOG_OPEN_FAILED', groupMessageErrorGeneric, { error });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const setGroupSubmitPending = (pending) => {
|
||||
if (groupSubmitBtn instanceof HTMLButtonElement) {
|
||||
groupSubmitBtn.disabled = pending;
|
||||
}
|
||||
groupForm.setAttribute('aria-busy', pending ? 'true' : 'false');
|
||||
};
|
||||
|
||||
const setGroupSubmitLabel = (label) => {
|
||||
if (!(groupSubmitBtn instanceof HTMLElement)) return;
|
||||
const text = String(label || '').trim();
|
||||
if (!text) return;
|
||||
groupSubmitBtn.textContent = text;
|
||||
};
|
||||
|
||||
const setGroupIcon = (icon) => {
|
||||
const value = String(icon || '').trim() || 'bi-folder';
|
||||
if (groupIconInput instanceof HTMLInputElement) {
|
||||
groupIconInput.value = value;
|
||||
}
|
||||
if (groupIconPicker instanceof HTMLElement) {
|
||||
groupIconPicker.querySelectorAll('.active').forEach((item) => item.classList.remove('active'));
|
||||
const selected = groupIconPicker.querySelector(`[data-icon="${value}"]`);
|
||||
if (selected instanceof HTMLElement) {
|
||||
selected.classList.add('active');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const upsertGroupOption = (group) => {
|
||||
if (!(groupSelect instanceof HTMLSelectElement)) {
|
||||
return;
|
||||
}
|
||||
const id = String(group?.id ?? '').trim();
|
||||
const name = String(group?.name ?? '').trim();
|
||||
if (!id || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = Array.from(groupSelect.options).find((option) => option.value === id);
|
||||
if (existing) {
|
||||
existing.textContent = name;
|
||||
} else {
|
||||
const option = document.createElement('option');
|
||||
option.value = id;
|
||||
option.textContent = name;
|
||||
groupSelect.appendChild(option);
|
||||
}
|
||||
};
|
||||
|
||||
const configureGroupDialog = ({ mode, source, id = null, name = '', icon = 'bi-folder', restoreState = null }) => {
|
||||
groupMode = mode === 'edit' ? 'edit' : 'create';
|
||||
groupSource = source === 'bookmark' ? 'bookmark' : 'sidebar';
|
||||
groupEditingId = groupMode === 'edit' ? id : null;
|
||||
bookmarkReturnState = groupSource === 'bookmark' ? restoreState : null;
|
||||
|
||||
groupForm.reset();
|
||||
setGroupIcon(icon);
|
||||
if (groupNameInput instanceof HTMLInputElement) {
|
||||
groupNameInput.value = String(name || '');
|
||||
}
|
||||
|
||||
if (groupTitleEl instanceof HTMLElement) {
|
||||
groupTitleEl.textContent = groupMode === 'edit'
|
||||
? (groupDialog.dataset.titleEdit || 'Edit group')
|
||||
: (groupDialog.dataset.titleCreate || 'New group');
|
||||
}
|
||||
setGroupSubmitLabel(groupMode === 'edit'
|
||||
? (groupDialog.dataset.labelUpdate || 'Save')
|
||||
: (groupDialog.dataset.labelSave || 'Save'));
|
||||
};
|
||||
|
||||
const handleGroupCancel = () => {
|
||||
closeGroupDialog();
|
||||
if (groupSource === 'bookmark' && bookmarkReturnState) {
|
||||
restoreBookmarkFromState();
|
||||
} else {
|
||||
bookmarkReturnState = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateGroupFromBookmarkDialog = () => {
|
||||
const state = captureBookmarkState();
|
||||
closeBookmarkDialog();
|
||||
configureGroupDialog({
|
||||
mode: 'create',
|
||||
source: 'bookmark',
|
||||
restoreState: state
|
||||
});
|
||||
if (openGroupDialog() && groupNameInput instanceof HTMLInputElement) {
|
||||
groupNameInput.focus();
|
||||
groupNameInput.select();
|
||||
}
|
||||
};
|
||||
|
||||
if (newGroupBtn instanceof HTMLButtonElement) {
|
||||
newGroupBtn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
openCreateGroupFromBookmarkDialog();
|
||||
});
|
||||
}
|
||||
|
||||
if (groupIconPicker instanceof HTMLElement) {
|
||||
groupIconPicker.addEventListener('click', (event) => {
|
||||
const target = event.target instanceof HTMLElement ? event.target.closest('[data-icon]') : null;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
event.preventDefault();
|
||||
setGroupIcon(target.dataset.icon || 'bi-folder');
|
||||
});
|
||||
}
|
||||
|
||||
groupCloseBtns.forEach((btn) => {
|
||||
btn.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
handleGroupCancel();
|
||||
});
|
||||
});
|
||||
|
||||
groupDialog.addEventListener('click', (event) => {
|
||||
if (event.target === groupDialog) {
|
||||
handleGroupCancel();
|
||||
}
|
||||
});
|
||||
groupDialog.addEventListener('cancel', (event) => {
|
||||
event.preventDefault();
|
||||
handleGroupCancel();
|
||||
});
|
||||
groupDialog.addEventListener('close', () => {
|
||||
groupForm.removeAttribute('aria-busy');
|
||||
syncModalBodyClass();
|
||||
});
|
||||
|
||||
document.addEventListener('app:bookmark-group-edit', (event) => {
|
||||
const detail = event instanceof CustomEvent ? event.detail : null;
|
||||
const groupId = Number.parseInt(String(detail?.id ?? ''), 10);
|
||||
const groupName = String(detail?.name ?? '').trim();
|
||||
const groupIcon = String(detail?.icon ?? 'bi-folder').trim() || 'bi-folder';
|
||||
if (groupId <= 0 || !groupName) {
|
||||
reportError('UI_INVALID_STATE', groupMessageErrorGeneric, { detail });
|
||||
return;
|
||||
}
|
||||
|
||||
configureGroupDialog({
|
||||
mode: 'edit',
|
||||
source: 'sidebar',
|
||||
id: groupId,
|
||||
name: groupName,
|
||||
icon: groupIcon
|
||||
});
|
||||
if (openGroupDialog() && groupNameInput instanceof HTMLInputElement) {
|
||||
groupNameInput.focus();
|
||||
groupNameInput.select();
|
||||
}
|
||||
});
|
||||
|
||||
groupForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const name = groupNameInput instanceof HTMLInputElement ? groupNameInput.value.trim() : '';
|
||||
if (!name) {
|
||||
showAsyncFlash('warning', groupMessageNameRequired);
|
||||
return;
|
||||
}
|
||||
const icon = groupIconInput instanceof HTMLInputElement ? groupIconInput.value : 'bi-folder';
|
||||
|
||||
const body = new FormData();
|
||||
body.set(csrfKey, csrfToken);
|
||||
body.set('name', name);
|
||||
body.set('icon', icon);
|
||||
if (groupMode === 'edit' && groupEditingId) {
|
||||
body.set('id', String(groupEditingId));
|
||||
}
|
||||
|
||||
setGroupSubmitPending(true);
|
||||
try {
|
||||
const response = await fetch(getAppBase() + 'bookmarks/group-save-data', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body
|
||||
});
|
||||
const json = await parseJsonSafe(response);
|
||||
if (!response.ok || !json) {
|
||||
reportError('FETCH_FAILED', groupMessageErrorGeneric, { status: response.status });
|
||||
return;
|
||||
}
|
||||
if (!json.ok) {
|
||||
if (json.error === 'max_reached') {
|
||||
showAsyncFlash('warning', maxGroupMessage);
|
||||
return;
|
||||
}
|
||||
reportError('FETCH_FAILED', resolveGroupApiErrorMessage(json.error), { error: json.error });
|
||||
return;
|
||||
}
|
||||
if (!json.group) {
|
||||
reportError('UI_INVALID_STATE', groupMessageErrorGeneric, { reason: 'missing_group' });
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = json.mode === 'updated' ? 'updated' : 'created';
|
||||
const successMessage = mode === 'updated' ? groupMessageUpdated : groupMessageCreated;
|
||||
|
||||
closeGroupDialog();
|
||||
if (groupSource === 'bookmark') {
|
||||
upsertGroupOption(json.group);
|
||||
restoreBookmarkFromState(String(json.group.id));
|
||||
showAsyncFlash('success', successMessage, 2000);
|
||||
} else {
|
||||
runReloadWithSuccess(successMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
reportError('FETCH_ERROR', groupMessageErrorGeneric, { error });
|
||||
} finally {
|
||||
setGroupSubmitPending(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getAppBase() {
|
||||
const base = document.querySelector('base');
|
||||
return base ? base.href : '/';
|
||||
}
|
||||
|
||||
function currentRelativeUrl() {
|
||||
const baseEl = document.querySelector('base');
|
||||
const basePath = baseEl ? new URL(baseEl.href).pathname : '/';
|
||||
const full = window.location.pathname + window.location.search;
|
||||
if (full.startsWith(basePath)) {
|
||||
return full.slice(basePath.length);
|
||||
}
|
||||
return full.replace(/^\//, '');
|
||||
}
|
||||
@@ -14,21 +14,12 @@ if (config) {
|
||||
const labels = config.labels ?? {};
|
||||
|
||||
const toolbar = document.querySelector('#address-book-drawer-toolbar');
|
||||
const saveFilterButton = document.querySelector('[data-address-book-save-filter]');
|
||||
const saveFilterForm = document.querySelector('#address-book-save-filter-form');
|
||||
const customFilterInputs = Array.from(document.querySelectorAll('[data-address-book-custom-filter]'));
|
||||
const tenantDepartmentMap = toolbar?.dataset?.tenantDeptMap
|
||||
? JSON.parse(toolbar.dataset.tenantDeptMap)
|
||||
: {};
|
||||
|
||||
const uuidIndex = 0;
|
||||
const normalizeCommaSeparated = (value) => {
|
||||
if (Array.isArray(value)) {return value.filter(Boolean);}
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const initialsForName = (name) => {
|
||||
const parts = String(name || '')
|
||||
@@ -139,71 +130,4 @@ if (config) {
|
||||
});
|
||||
});
|
||||
|
||||
const normalizeJoined = (value) => normalizeCommaSeparated(value).join(',');
|
||||
|
||||
if (saveFilterButton && saveFilterForm) {
|
||||
saveFilterButton.addEventListener('click', () => {
|
||||
if (!gridConfig) {
|
||||
return;
|
||||
}
|
||||
const promptText = saveFilterButton.dataset.saveFilterPrompt || '';
|
||||
const emptyText = saveFilterButton.dataset.saveFilterEmpty || '';
|
||||
const enteredName = window.prompt(promptText, '');
|
||||
if (enteredName === null) {
|
||||
return;
|
||||
}
|
||||
const name = String(enteredName).trim();
|
||||
if (!name) {
|
||||
if (emptyText) {
|
||||
window.alert(emptyText);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const appliedQuery = new URL(gridConfig.baseUrl()).searchParams;
|
||||
const state = {
|
||||
search: String(appliedQuery.get('search') || '').trim(),
|
||||
tenants: normalizeJoined(appliedQuery.get('tenants') || ''),
|
||||
departments: normalizeJoined(appliedQuery.get('departments') || ''),
|
||||
roles: normalizeJoined(appliedQuery.get('roles') || ''),
|
||||
custom: {},
|
||||
};
|
||||
appliedQuery.forEach((value, key) => {
|
||||
if (!/^(cf_|cfm_|cfd_)/i.test(key)) {
|
||||
return;
|
||||
}
|
||||
const cleanedValue = String(value || '').trim();
|
||||
if (cleanedValue === '') {
|
||||
return;
|
||||
}
|
||||
state.custom[key] = cleanedValue;
|
||||
});
|
||||
const nameInput = saveFilterForm.querySelector('input[name="name"]');
|
||||
const searchInput = saveFilterForm.querySelector('input[name="search"]');
|
||||
const tenantsInput = saveFilterForm.querySelector('input[name="tenants"]');
|
||||
const departmentsInput = saveFilterForm.querySelector('input[name="departments"]');
|
||||
const rolesInput = saveFilterForm.querySelector('input[name="roles"]');
|
||||
const extrasContainer = saveFilterForm.querySelector('[data-address-book-save-filter-extra]');
|
||||
if (!nameInput || !searchInput || !tenantsInput || !departmentsInput || !rolesInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
nameInput.value = name;
|
||||
searchInput.value = state.search;
|
||||
tenantsInput.value = state.tenants;
|
||||
departmentsInput.value = state.departments;
|
||||
rolesInput.value = state.roles;
|
||||
if (extrasContainer) {
|
||||
extrasContainer.innerHTML = '';
|
||||
Object.entries(state.custom || {}).forEach(([key, value]) => {
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = key;
|
||||
hidden.value = String(value ?? '');
|
||||
extrasContainer.appendChild(hidden);
|
||||
});
|
||||
}
|
||||
saveFilterForm.requestSubmit();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user