From 9e3059f6033bce7da9a3ac0a7ee3caf8a7f37979 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 23 Apr 2026 17:58:38 +0100 Subject: [PATCH 1/6] fix(website): resolve hub.* route-name collisions across domain groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat the first resolved domain as canonical (keeps the `hub.` name prefix); secondary-domain registrations get routed through a prefixSecondaryDomainRoutes() helper that renames only newly-added routes with a domain-qualified slug prefix. Clears the remaining 25 hub.* collisions that originate in this vendored package — the pattern mirrors what already landed for app.*, api.docs.*, lthn.*, mcp.* in lthn.ai (commit 0de9ad9). Closes tasks.lthn.sh/view.php?id=88 Co-authored-by: Codex Co-Authored-By: Virgil --- src/Website/Hub/Boot.php | 44 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/Website/Hub/Boot.php b/src/Website/Hub/Boot.php index 0b74e4c..8f3c6fc 100644 --- a/src/Website/Hub/Boot.php +++ b/src/Website/Hub/Boot.php @@ -91,14 +91,52 @@ public function onAdminPanel(AdminPanelBooting $event): void app(AdminMenuRegistry::class)->register($this); // Register routes for configured domains + $primary = true; + foreach ($this->domains() as $domain) { - $event->routes(fn () => Route::prefix('hub') - ->name('hub.') + if ($primary) { + $event->routes(fn () => Route::prefix('hub') + ->name('hub.') + ->domain($domain) + ->group(__DIR__.'/Routes/admin.php')); + + $primary = false; + + continue; + } + + $event->routes(fn () => $this->prefixSecondaryDomainRoutes($domain, fn () => Route::prefix('hub') ->domain($domain) - ->group(__DIR__.'/Routes/admin.php')); + ->group(__DIR__.'/Routes/admin.php'))); } } + private function prefixSecondaryDomainRoutes(string $domain, callable $register): void + { + $routes = Route::getRoutes(); + $existingRoutes = []; + foreach ($routes->getRoutes() as $route) { + $existingRoutes[spl_object_id($route)] = true; + } + $register(); + foreach ($routes->getRoutes() as $route) { + if (isset($existingRoutes[spl_object_id($route)])) { + continue; + } + $name = $route->getName(); + if ($name === null) { + continue; + } + $route->action['as'] = self::domainRoutePrefix($domain).$name; + } + $routes->refreshNameLookups(); + } + + private static function domainRoutePrefix(string $domain): string + { + return strtr($domain, ['.' => '_', '-' => '_']).'.'; + } + /** * Provide admin menu items. */ From 191b1807e04ab3029e97f33a82dfdd1e5052e6f7 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 04:44:30 +0100 Subject: [PATCH 2/6] =?UTF-8?q?feat(admin):=20implement=20=C2=A73=20Search?= =?UTF-8?q?=20System=20(provider=20contract=20+=20dispatcher=20+=202=20pro?= =?UTF-8?q?viders)=20(#855)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchProvider interface (search/getLabel/getPriority) - SearchResult DTO (title/subtitle/url/icon/category/score) — readonly with legacy registry compatibility preserved - SearchDispatcher — register providers, gather, sort by score desc - UserSearchProvider, WorkspaceSearchProvider as built-ins - Pest Feature tests _Good/_Bad/_Ugly per AX-10 - pint/pest skipped (vendor binaries missing in sandbox) Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=855 --- src/Search/Providers/UserSearchProvider.php | 102 ++++ .../Providers/WorkspaceSearchProvider.php | 102 ++++ src/Search/SearchDispatcher.php | 97 ++++ src/Search/SearchProvider.php | 32 ++ src/Search/SearchResult.php | 100 ++-- tests/Feature/Search/SearchSystemTest.php | 435 ++++++++++++++++++ 6 files changed, 839 insertions(+), 29 deletions(-) create mode 100644 src/Search/Providers/UserSearchProvider.php create mode 100644 src/Search/Providers/WorkspaceSearchProvider.php create mode 100644 src/Search/SearchDispatcher.php create mode 100644 src/Search/SearchProvider.php create mode 100644 tests/Feature/Search/SearchSystemTest.php diff --git a/src/Search/Providers/UserSearchProvider.php b/src/Search/Providers/UserSearchProvider.php new file mode 100644 index 0000000..ba7dca0 --- /dev/null +++ b/src/Search/Providers/UserSearchProvider.php @@ -0,0 +1,102 @@ + $modelClass + */ + public function __construct( + private readonly string $modelClass = User::class, + private readonly int $limit = 10 + ) {} + + /** + * @return array + */ + public function search(string $query): array + { + $query = trim($query); + + if ($query === '' || ! is_a($this->modelClass, Model::class, true)) { + return []; + } + + $term = $this->likeTerm($query); + $modelClass = $this->modelClass; + + return $modelClass::query() + ->where(function (Builder $builder) use ($term): void { + $builder->where('name', 'like', $term) + ->orWhere('email', 'like', $term); + }) + ->limit($this->limit) + ->get() + ->map(fn (Model $user): SearchResult => $this->resultFor($user, $query)) + ->all(); + } + + public function getLabel(): string + { + return 'Users'; + } + + public function getPriority(): int + { + return 100; + } + + private function resultFor(Model $user, string $query): SearchResult + { + $name = (string) ($user->getAttribute('name') ?? ''); + $email = (string) ($user->getAttribute('email') ?? ''); + $title = $name !== '' ? $name : $email; + + return new SearchResult( + title: $title, + subtitle: $email !== '' ? $email : null, + url: '/hub/platform/user/'.$user->getKey(), + icon: 'fa-user', + category: $this->getLabel(), + score: $this->score($query, $name, $email), + ); + } + + private function likeTerm(string $query): string + { + return '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query).'%'; + } + + private function score(string $query, string $name, string $email): int + { + $query = strtolower($query); + $name = strtolower($name); + $email = strtolower($email); + + return match (true) { + $name === $query => 100, + str_starts_with($name, $query) => 90, + $email === $query => 85, + str_contains($name, $query) => 80, + str_starts_with($email, $query) => 75, + str_contains($email, $query) => 70, + default => 50, + }; + } +} diff --git a/src/Search/Providers/WorkspaceSearchProvider.php b/src/Search/Providers/WorkspaceSearchProvider.php new file mode 100644 index 0000000..0ea0c9c --- /dev/null +++ b/src/Search/Providers/WorkspaceSearchProvider.php @@ -0,0 +1,102 @@ + $modelClass + */ + public function __construct( + private readonly string $modelClass = Workspace::class, + private readonly int $limit = 10 + ) {} + + /** + * @return array + */ + public function search(string $query): array + { + $query = trim($query); + + if ($query === '' || ! is_a($this->modelClass, Model::class, true)) { + return []; + } + + $term = $this->likeTerm($query); + $modelClass = $this->modelClass; + + return $modelClass::query() + ->where(function (Builder $builder) use ($term): void { + $builder->where('name', 'like', $term) + ->orWhere('slug', 'like', $term); + }) + ->limit($this->limit) + ->get() + ->map(fn (Model $workspace): SearchResult => $this->resultFor($workspace, $query)) + ->all(); + } + + public function getLabel(): string + { + return 'Workspaces'; + } + + public function getPriority(): int + { + return 90; + } + + private function resultFor(Model $workspace, string $query): SearchResult + { + $name = (string) ($workspace->getAttribute('name') ?? ''); + $slug = (string) ($workspace->getAttribute('slug') ?? ''); + $title = $name !== '' ? $name : $slug; + + return new SearchResult( + title: $title, + subtitle: $slug, + url: '/hub/workspaces/'.$slug, + icon: 'fa-folder', + category: $this->getLabel(), + score: $this->score($query, $name, $slug), + ); + } + + private function likeTerm(string $query): string + { + return '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query).'%'; + } + + private function score(string $query, string $name, string $slug): int + { + $query = strtolower($query); + $name = strtolower($name); + $slug = strtolower($slug); + + return match (true) { + $name === $query => 100, + str_starts_with($name, $query) => 90, + $slug === $query => 85, + str_contains($name, $query) => 80, + str_starts_with($slug, $query) => 75, + str_contains($slug, $query) => 70, + default => 50, + }; + } +} diff --git a/src/Search/SearchDispatcher.php b/src/Search/SearchDispatcher.php new file mode 100644 index 0000000..a41adad --- /dev/null +++ b/src/Search/SearchDispatcher.php @@ -0,0 +1,97 @@ + + */ + private array $providers = []; + + /** + * @param iterable $providers + */ + public function __construct(iterable $providers = []) + { + foreach ($providers as $provider) { + $this->register($provider); + } + } + + public function register(SearchProvider $provider): self + { + $this->providers[] = $provider; + + return $this; + } + + /** + * @return array + */ + public function providers(): array + { + return $this->providers; + } + + /** + * Gather results from all providers and rank by score descending. + * + * @return array + */ + public function search(string $query): array + { + $query = trim($query); + + if ($query === '') { + return []; + } + + $ranked = []; + $index = 0; + + foreach ($this->providers as $provider) { + foreach ($provider->search($query) as $result) { + if (! $result instanceof SearchResult) { + continue; + } + + $ranked[] = [ + 'result' => $result, + 'priority' => $provider->getPriority(), + 'index' => $index++, + ]; + } + } + + usort($ranked, static function (array $left, array $right): int { + $score = $right['result']->score <=> $left['result']->score; + + if ($score !== 0) { + return $score; + } + + $priority = $right['priority'] <=> $left['priority']; + + if ($priority !== 0) { + return $priority; + } + + return $left['index'] <=> $right['index']; + }); + + return array_map( + static fn (array $entry): SearchResult => $entry['result'], + $ranked + ); + } +} diff --git a/src/Search/SearchProvider.php b/src/Search/SearchProvider.php new file mode 100644 index 0000000..16676bb --- /dev/null +++ b/src/Search/SearchProvider.php @@ -0,0 +1,32 @@ + + */ + public function search(string $query): array; + + /** + * Get the provider label for grouping and display. + */ + public function getLabel(): string; + + /** + * Get the provider priority for deterministic tie-breaking. + */ + public function getPriority(): int; +} diff --git a/src/Search/SearchResult.php b/src/Search/SearchResult.php index 7035317..2985ad6 100644 --- a/src/Search/SearchResult.php +++ b/src/Search/SearchResult.php @@ -14,35 +14,40 @@ use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; -/** - * Data transfer object for search results. - * - * Represents a single search result from a SearchProvider. Implements - * Arrayable and JsonSerializable for easy serialization to Livewire - * and JavaScript. - */ final class SearchResult implements Arrayable, JsonSerializable { - /** - * Create a new search result instance. - * - * @param string $id Unique identifier for the result - * @param string $title Primary display text - * @param string $url Navigation URL - * @param string $type The search type (from provider) - * @param string $icon Icon name for display - * @param string|null $subtitle Secondary display text - * @param array $meta Additional metadata - */ - public function __construct( - public readonly string $id, - public readonly string $title, - public readonly string $url, - public readonly string $type, - public readonly string $icon, - public readonly ?string $subtitle = null, - public readonly array $meta = [], - ) {} + public readonly string $id; + + public readonly string $title; + + public readonly ?string $subtitle; + + public readonly string $url; + + public readonly string $icon; + + public readonly string $category; + + public readonly int $score; + + public readonly string $type; + + public readonly array $meta; + + public function __construct(mixed ...$arguments) + { + $data = self::normaliseConstructorArguments($arguments); + + $this->id = (string) ($data['id'] ?? uniqid('', true)); + $this->title = (string) ($data['title'] ?? ''); + $this->subtitle = isset($data['subtitle']) ? (string) $data['subtitle'] : null; + $this->url = (string) ($data['url'] ?? '#'); + $this->type = (string) ($data['type'] ?? $data['category'] ?? 'unknown'); + $this->icon = (string) ($data['icon'] ?? 'document'); + $this->category = (string) ($data['category'] ?? $this->type); + $this->score = (int) ($data['score'] ?? 0); + $this->meta = is_array($data['meta'] ?? null) ? $data['meta'] : []; + } /** * Create a SearchResult from an array. @@ -50,13 +55,15 @@ public function __construct( public static function fromArray(array $data): static { return new self( - id: (string) ($data['id'] ?? uniqid()), + id: (string) ($data['id'] ?? uniqid('', true)), title: (string) ($data['title'] ?? ''), url: (string) ($data['url'] ?? '#'), - type: (string) ($data['type'] ?? 'unknown'), + type: (string) ($data['type'] ?? $data['category'] ?? 'unknown'), icon: (string) ($data['icon'] ?? 'document'), subtitle: $data['subtitle'] ?? null, meta: $data['meta'] ?? [], + category: (string) ($data['category'] ?? $data['type'] ?? 'unknown'), + score: (int) ($data['score'] ?? 0), ); } @@ -75,6 +82,8 @@ public function withTypeAndIcon(string $type, string $icon): static icon: $this->icon !== 'document' ? $this->icon : $icon, subtitle: $this->subtitle, meta: $this->meta, + category: $type, + score: $this->score, ); } @@ -101,4 +110,37 @@ public function jsonSerialize(): array { return $this->toArray(); } + + /** + * Normalise both the legacy registry constructor and the new DTO shape. + */ + private static function normaliseConstructorArguments(array $arguments): array + { + if (! array_is_list($arguments)) { + return $arguments; + } + + if (count($arguments) >= 6 && is_numeric($arguments[5])) { + return [ + 'title' => $arguments[0] ?? '', + 'subtitle' => $arguments[1] ?? null, + 'url' => $arguments[2] ?? '#', + 'icon' => $arguments[3] ?? 'document', + 'category' => $arguments[4] ?? 'unknown', + 'type' => $arguments[4] ?? 'unknown', + 'score' => $arguments[5] ?? 0, + ]; + } + + return [ + 'id' => $arguments[0] ?? uniqid('', true), + 'title' => $arguments[1] ?? '', + 'url' => $arguments[2] ?? '#', + 'type' => $arguments[3] ?? 'unknown', + 'icon' => $arguments[4] ?? 'document', + 'subtitle' => $arguments[5] ?? null, + 'meta' => $arguments[6] ?? [], + 'category' => $arguments[3] ?? 'unknown', + ]; + } } diff --git a/tests/Feature/Search/SearchSystemTest.php b/tests/Feature/Search/SearchSystemTest.php new file mode 100644 index 0000000..db9717b --- /dev/null +++ b/tests/Feature/Search/SearchSystemTest.php @@ -0,0 +1,435 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + + Model::unguard(); + + return $capsule; +} + +function resetSearchSystemTables(): void +{ + $schema = searchSystemDatabase()->schema(); + + $schema->dropIfExists('search_system_users'); + $schema->create('search_system_users', function (Blueprint $table): void { + $table->id(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + }); + + $schema->dropIfExists('search_system_workspaces'); + $schema->create('search_system_workspaces', function (Blueprint $table): void { + $table->id(); + $table->string('name')->nullable(); + $table->string('slug')->nullable(); + }); +} + +beforeEach(function (): void { + resetSearchSystemTables(); +}); + +afterAll(function (): void { + $resolver = searchSystemOriginalResolver(); + + if ($resolver !== null) { + Model::setConnectionResolver($resolver); + + return; + } + + Model::unsetConnectionResolver(); +}); + +describe('SearchProvider contract', function (): void { + it('Good: exposes search results, label, and priority', function (): void { + $provider = new class implements SearchProvider + { + public function search(string $query): array + { + return [ + new SearchResult( + title: 'Dashboard', + subtitle: 'Overview', + url: '/hub', + icon: 'fa-gauge', + category: 'Pages', + score: 90, + ), + ]; + } + + public function getLabel(): string + { + return 'Pages'; + } + + public function getPriority(): int + { + return 50; + } + }; + + expect($provider->getLabel())->toBe('Pages') + ->and($provider->getPriority())->toBe(50) + ->and($provider->search('dash'))->toHaveCount(1) + ->and($provider->search('dash')[0])->toBeInstanceOf(SearchResult::class); + }); + + it('Bad: allows a provider to return no matches', function (): void { + $provider = new class implements SearchProvider + { + public function search(string $query): array + { + return []; + } + + public function getLabel(): string + { + return 'Empty'; + } + + public function getPriority(): int + { + return 0; + } + }; + + expect($provider->search('missing'))->toBe([]); + }); + + it('Ugly: dispatcher ignores non-result payloads from a loose provider', function (): void { + $provider = new class implements SearchProvider + { + public function search(string $query): array + { + return [ + ['title' => 'Array payload'], + new SearchResult( + title: 'Real payload', + subtitle: 'Valid DTO', + url: '/real', + icon: 'fa-check', + category: 'Valid', + score: 10, + ), + ]; + } + + public function getLabel(): string + { + return 'Mixed'; + } + + public function getPriority(): int + { + return 1; + } + }; + + $results = (new SearchDispatcher([$provider]))->search('payload'); + + expect($results)->toHaveCount(1) + ->and($results[0]->title)->toBe('Real payload'); + }); +}); + +describe('SearchResult DTO', function (): void { + it('Good: stores the required readonly result fields', function (): void { + $result = new SearchResult( + title: 'Alice Admin', + subtitle: 'alice@example.test', + url: '/hub/platform/user/1', + icon: 'fa-user', + category: 'Users', + score: 100, + ); + + expect($result->title)->toBe('Alice Admin') + ->and($result->subtitle)->toBe('alice@example.test') + ->and($result->url)->toBe('/hub/platform/user/1') + ->and($result->icon)->toBe('fa-user') + ->and($result->category)->toBe('Users') + ->and($result->score)->toBe(100); + }); + + it('Bad: applies sensible defaults for partial result data', function (): void { + $result = new SearchResult(title: 'Untyped'); + + expect($result->title)->toBe('Untyped') + ->and($result->subtitle)->toBeNull() + ->and($result->url)->toBe('#') + ->and($result->icon)->toBe('document') + ->and($result->category)->toBe('unknown') + ->and($result->score)->toBe(0); + }); + + it('Ugly: supports both new and legacy positional construction', function (): void { + $newShape = new SearchResult('Workspace', 'primary-site', '/hub/workspaces/primary-site', 'fa-folder', 'Workspaces', 85); + $legacyShape = new SearchResult('user-1', 'Alice Admin', '/hub/platform/user/1', 'users', 'fa-user', 'alice@example.test'); + + expect($newShape->title)->toBe('Workspace') + ->and($newShape->category)->toBe('Workspaces') + ->and($newShape->score)->toBe(85) + ->and($legacyShape->id)->toBe('user-1') + ->and($legacyShape->title)->toBe('Alice Admin') + ->and($legacyShape->type)->toBe('users'); + }); +}); + +describe('UserSearchProvider', function (): void { + it('Good: finds users by name and email using Eloquent LIKE queries', function (): void { + SearchSystemUserModel::query()->create(['name' => 'Alice Admin', 'email' => 'alice@example.test']); + SearchSystemUserModel::query()->create(['name' => 'Bob Builder', 'email' => 'bob@example.test']); + + $provider = new UserSearchProvider(SearchSystemUserModel::class); + + $nameResults = $provider->search('Alice'); + $emailResults = $provider->search('bob@example'); + + expect($nameResults)->toHaveCount(1) + ->and($nameResults[0]->title)->toBe('Alice Admin') + ->and($nameResults[0]->subtitle)->toBe('alice@example.test') + ->and($nameResults[0]->category)->toBe('Users') + ->and($emailResults)->toHaveCount(1) + ->and($emailResults[0]->title)->toBe('Bob Builder'); + }); + + it('Bad: returns no results for blank user queries', function (): void { + SearchSystemUserModel::query()->create(['name' => 'Alice Admin', 'email' => 'alice@example.test']); + + $provider = new UserSearchProvider(SearchSystemUserModel::class); + + expect($provider->search(' '))->toBe([]); + }); + + it('Ugly: escapes LIKE wildcards instead of broad matching every user', function (): void { + SearchSystemUserModel::query()->create(['name' => 'Alice Admin', 'email' => 'alice@example.test']); + SearchSystemUserModel::query()->create(['name' => 'Bob Builder', 'email' => 'bob@example.test']); + + $provider = new UserSearchProvider(SearchSystemUserModel::class); + + expect($provider->search('%'))->toBe([]); + }); +}); + +describe('WorkspaceSearchProvider', function (): void { + it('Good: finds workspaces by name and slug using Eloquent LIKE queries', function (): void { + SearchSystemWorkspaceModel::query()->create(['name' => 'Primary Site', 'slug' => 'primary-site']); + SearchSystemWorkspaceModel::query()->create(['name' => 'Docs Centre', 'slug' => 'docs-centre']); + + $provider = new WorkspaceSearchProvider(SearchSystemWorkspaceModel::class); + + $nameResults = $provider->search('Primary'); + $slugResults = $provider->search('docs-centre'); + + expect($nameResults)->toHaveCount(1) + ->and($nameResults[0]->title)->toBe('Primary Site') + ->and($nameResults[0]->url)->toBe('/hub/workspaces/primary-site') + ->and($nameResults[0]->category)->toBe('Workspaces') + ->and($slugResults)->toHaveCount(1) + ->and($slugResults[0]->title)->toBe('Docs Centre'); + }); + + it('Bad: returns no results for blank workspace queries', function (): void { + SearchSystemWorkspaceModel::query()->create(['name' => 'Primary Site', 'slug' => 'primary-site']); + + $provider = new WorkspaceSearchProvider(SearchSystemWorkspaceModel::class); + + expect($provider->search("\n\t"))->toBe([]); + }); + + it('Ugly: escapes LIKE wildcards instead of broad matching every workspace', function (): void { + SearchSystemWorkspaceModel::query()->create(['name' => 'Primary Site', 'slug' => 'primary-site']); + SearchSystemWorkspaceModel::query()->create(['name' => 'Docs Centre', 'slug' => 'docs-centre']); + + $provider = new WorkspaceSearchProvider(SearchSystemWorkspaceModel::class); + + expect($provider->search('_'))->toBe([]); + }); +}); + +describe('SearchDispatcher', function (): void { + it('Good: gathers provider results and ranks by score descending', function (): void { + $users = new class implements SearchProvider + { + public function search(string $query): array + { + return [ + new SearchResult(title: 'Lower', subtitle: null, url: '/low', icon: 'fa-user', category: 'Users', score: 60), + ]; + } + + public function getLabel(): string + { + return 'Users'; + } + + public function getPriority(): int + { + return 100; + } + }; + + $workspaces = new class implements SearchProvider + { + public function search(string $query): array + { + return [ + new SearchResult(title: 'Higher', subtitle: null, url: '/high', icon: 'fa-folder', category: 'Workspaces', score: 95), + ]; + } + + public function getLabel(): string + { + return 'Workspaces'; + } + + public function getPriority(): int + { + return 90; + } + }; + + $results = (new SearchDispatcher([$users, $workspaces]))->search('query'); + + expect($results)->toHaveCount(2) + ->and($results[0]->title)->toBe('Higher') + ->and($results[1]->title)->toBe('Lower'); + }); + + it('Bad: skips provider calls for empty dispatcher queries', function (): void { + $provider = new class implements SearchProvider + { + public bool $called = false; + + public function search(string $query): array + { + $this->called = true; + + return []; + } + + public function getLabel(): string + { + return 'Never Called'; + } + + public function getPriority(): int + { + return 1; + } + }; + + $results = (new SearchDispatcher([$provider]))->search(' '); + + expect($results)->toBe([]) + ->and($provider->called)->toBeFalse(); + }); + + it('Ugly: uses provider priority as a deterministic score tie-breaker', function (): void { + $lowPriority = new class implements SearchProvider + { + public function search(string $query): array + { + return [ + new SearchResult(title: 'Low priority', subtitle: null, url: '/low', icon: 'fa-user', category: 'Users', score: 80), + ]; + } + + public function getLabel(): string + { + return 'Low'; + } + + public function getPriority(): int + { + return 1; + } + }; + + $highPriority = new class implements SearchProvider + { + public function search(string $query): array + { + return [ + new SearchResult(title: 'High priority', subtitle: null, url: '/high', icon: 'fa-folder', category: 'Workspaces', score: 80), + ]; + } + + public function getLabel(): string + { + return 'High'; + } + + public function getPriority(): int + { + return 99; + } + }; + + $results = (new SearchDispatcher([$lowPriority, $highPriority]))->search('query'); + + expect($results[0]->title)->toBe('High priority') + ->and($results[1]->title)->toBe('Low priority'); + }); +}); From cf88f319a8f012c40266885d95e8c573f0713079 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 05:01:31 +0100 Subject: [PATCH 3/6] =?UTF-8?q?feat(admin):=20implement=20=C2=A76=20AdminM?= =?UTF-8?q?enuRegistry=20+=20AdminMenuProvider=20contract=20(#854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminMenuProvider interface (getMenuItems/getMenuGroups/getPriority) - MenuItem + MenuGroup readonly DTOs - AdminMenuRegistry: provider registration, RFC default groups, custom group merging, sorted grouped resolution, flat items() - src/Boot.php registers the registry singleton - Pest Feature tests _Good/_Bad/_Ugly per AX-10 (replaces legacy menu-system tests that tested the prior implementation) - pint/pest skipped (vendor binaries missing in sandbox) Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=854 --- src/Boot.php | 1 + src/Menu/AdminMenuProvider.php | 42 + src/Menu/AdminMenuRegistry.php | 287 +++++ src/Menu/MenuGroup.php | 84 ++ src/Menu/MenuItem.php | 128 +++ tests/Feature/Menu/AdminMenuSystemTest.php | 1176 +++----------------- 6 files changed, 682 insertions(+), 1036 deletions(-) create mode 100644 src/Menu/AdminMenuProvider.php create mode 100644 src/Menu/AdminMenuRegistry.php create mode 100644 src/Menu/MenuGroup.php create mode 100644 src/Menu/MenuItem.php diff --git a/src/Boot.php b/src/Boot.php index 53b1ea2..99f1736 100644 --- a/src/Boot.php +++ b/src/Boot.php @@ -34,6 +34,7 @@ public function register(): void // Register the search provider registry as a singleton $this->app->singleton(SearchProviderRegistry::class); + $this->app->singleton(\Core\Admin\Menu\AdminMenuRegistry::class); } public function boot(): void diff --git a/src/Menu/AdminMenuProvider.php b/src/Menu/AdminMenuProvider.php new file mode 100644 index 0000000..8340613 --- /dev/null +++ b/src/Menu/AdminMenuProvider.php @@ -0,0 +1,42 @@ + + */ + public function getMenuItems(): array; + + /** + * Menu groups supplied by this provider. + * + * @return array + */ + public function getMenuGroups(): array; + + /** + * Provider priority used as a deterministic tie-breaker. + * + * Lower values resolve earlier. Higher priority appears later. + */ + public function getPriority(): int; +} diff --git a/src/Menu/AdminMenuRegistry.php b/src/Menu/AdminMenuRegistry.php new file mode 100644 index 0000000..b0cd300 --- /dev/null +++ b/src/Menu/AdminMenuRegistry.php @@ -0,0 +1,287 @@ + + */ + private array $providers = []; + + /** + * Register a menu provider. + */ + public function register(AdminMenuProvider $provider): void + { + $this->providers[] = $provider; + } + + /** + * Register multiple menu providers. + * + * @param array $providers + */ + public function registerMany(array $providers): void + { + foreach ($providers as $provider) { + $this->register($provider); + } + } + + /** + * Get all registered providers in registration order. + * + * @return array + */ + public function providers(): array + { + return $this->providers; + } + + /** + * Get all known menu groups sorted by priority. + * + * @return array + */ + public function groups(): array + { + return $this->resolveGroups(); + } + + /** + * Resolve providers into grouped and sorted menu definitions. + * + * @return array}> + */ + public function resolve(): array + { + $groups = $this->resolveGroups(); + $entries = []; + + foreach ($this->sortedProviders() as $providerIndex => $provider) { + foreach ($provider->getMenuItems() as $itemIndex => $item) { + $menuItem = $this->normaliseMenuItem($item); + + if (! isset($groups[$menuItem->group])) { + $groups[$menuItem->group] = new MenuGroup( + key: $menuItem->group, + label: MenuGroup::labelFromKey($menuItem->group), + ); + } + + $entries[] = [ + 'item' => $menuItem, + 'providerPriority' => $provider->getPriority(), + 'providerIndex' => $providerIndex, + 'itemIndex' => $itemIndex, + ]; + } + } + + $groups = $this->sortGroups($groups); + + usort($entries, function (array $left, array $right) use ($groups): int { + /** @var MenuItem $leftItem */ + $leftItem = $left['item']; + /** @var MenuItem $rightItem */ + $rightItem = $right['item']; + + return [ + $groups[$leftItem->group]->priority, + $leftItem->priority, + $left['providerPriority'], + $left['providerIndex'], + $left['itemIndex'], + $leftItem->label, + ] <=> [ + $groups[$rightItem->group]->priority, + $rightItem->priority, + $right['providerPriority'], + $right['providerIndex'], + $right['itemIndex'], + $rightItem->label, + ]; + }); + + $resolved = []; + + foreach ($entries as $entry) { + /** @var MenuItem $item */ + $item = $entry['item']; + $group = $groups[$item->group]; + + if (! isset($resolved[$group->key])) { + $resolved[$group->key] = [ + 'group' => $group, + 'items' => [], + ]; + } + + $resolved[$group->key]['items'][] = $item; + } + + return $resolved; + } + + /** + * Resolve to a flat sorted item list. + * + * @return array + */ + public function items(): array + { + $items = []; + + foreach ($this->resolve() as $group) { + array_push($items, ...$group['items']); + } + + return $items; + } + + /** + * Get default RFC groups. + * + * @return array + */ + private function defaultGroups(): array + { + return [ + 'dashboard' => new MenuGroup('dashboard', 'Dashboard', 10), + 'webhost' => new MenuGroup('webhost', 'Web Hosting', 20), + 'services' => new MenuGroup('services', 'Services', 30), + 'settings' => new MenuGroup('settings', 'Settings', 40), + 'admin' => new MenuGroup('admin', 'Admin', 50), + ]; + } + + /** + * Resolve default and provider-supplied groups. + * + * @return array + */ + private function resolveGroups(): array + { + $groups = $this->defaultGroups(); + + foreach ($this->sortedProviders() as $provider) { + foreach ($provider->getMenuGroups() as $group) { + $menuGroup = $this->normaliseMenuGroup($group); + + if (! isset($groups[$menuGroup->key])) { + $groups[$menuGroup->key] = $menuGroup; + } + } + } + + return $this->sortGroups($groups); + } + + /** + * Sort groups by priority and key. + * + * @param array $groups + * @return array + */ + private function sortGroups(array $groups): array + { + uasort($groups, fn (MenuGroup $left, MenuGroup $right): int => [ + $left->priority, + $left->key, + ] <=> [ + $right->priority, + $right->key, + ]); + + return $groups; + } + + /** + * Sort providers by priority while preserving registration order ties. + * + * @return array + */ + private function sortedProviders(): array + { + $providers = []; + + foreach ($this->providers as $index => $provider) { + $providers[] = [ + 'provider' => $provider, + 'index' => $index, + ]; + } + + usort($providers, fn (array $left, array $right): int => [ + $left['provider']->getPriority(), + $left['index'], + ] <=> [ + $right['provider']->getPriority(), + $right['index'], + ]); + + return array_map( + fn (array $entry): AdminMenuProvider => $entry['provider'], + $providers + ); + } + + /** + * @param mixed $item + */ + private function normaliseMenuItem(mixed $item): MenuItem + { + if ($item instanceof MenuItem) { + return $item; + } + + if (is_array($item)) { + return MenuItem::fromArray($item); + } + + throw new InvalidArgumentException(sprintf( + 'Menu providers must return %s instances.', + MenuItem::class + )); + } + + /** + * @param mixed $group + */ + private function normaliseMenuGroup(mixed $group): MenuGroup + { + if ($group instanceof MenuGroup) { + return $group; + } + + if (is_array($group)) { + return MenuGroup::fromArray($group); + } + + throw new InvalidArgumentException(sprintf( + 'Menu providers must return %s instances from getMenuGroups().', + MenuGroup::class + )); + } +} diff --git a/src/Menu/MenuGroup.php b/src/Menu/MenuGroup.php new file mode 100644 index 0000000..2748d62 --- /dev/null +++ b/src/Menu/MenuGroup.php @@ -0,0 +1,84 @@ +key) === '') { + throw new InvalidArgumentException('Menu group key cannot be empty.'); + } + + if (trim($this->label) === '') { + throw new InvalidArgumentException('Menu group label cannot be empty.'); + } + } + + /** + * Create a menu group from an array payload. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + $key = (string) ($data['key'] ?? ''); + + return new self( + key: $key, + label: (string) ($data['label'] ?? self::labelFromKey($key)), + priority: (int) ($data['priority'] ?? 50), + ); + } + + /** + * Create a human-readable label from a group key. + */ + public static function labelFromKey(string $key): string + { + return ucwords(str_replace(['-', '_'], ' ', $key)); + } + + /** + * Convert the group to an array for rendering. + * + * @return array{key: string, label: string, priority: int} + */ + public function toArray(): array + { + return [ + 'key' => $this->key, + 'label' => $this->label, + 'priority' => $this->priority, + ]; + } + + /** + * Specify data which should be serialized to JSON. + * + * @return array{key: string, label: string, priority: int} + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Menu/MenuItem.php b/src/Menu/MenuItem.php new file mode 100644 index 0000000..0d5f30f --- /dev/null +++ b/src/Menu/MenuItem.php @@ -0,0 +1,128 @@ +label) === '') { + throw new InvalidArgumentException('Menu item label cannot be empty.'); + } + + if (is_string($this->route) && trim($this->route) === '') { + throw new InvalidArgumentException('Menu item route cannot be empty.'); + } + + if (trim($this->icon) === '') { + throw new InvalidArgumentException('Menu item icon cannot be empty.'); + } + + if (trim($this->group) === '') { + throw new InvalidArgumentException('Menu item group cannot be empty.'); + } + } + + /** + * Create a menu item from an array payload. + * + * This accepts both the DTO shape and the RFC's lazy `item` wrapper shape + * to keep module registration concise. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + $payload = $data; + + if (array_key_exists('item', $data)) { + $item = $data['item']; + $payload = $item instanceof Closure ? $item() : $item; + + if (! is_array($payload)) { + throw new InvalidArgumentException('Lazy menu item definitions must resolve to an array.'); + } + + $payload = array_merge( + array_diff_key($data, ['item' => true]), + $payload + ); + } + + $route = $payload['route'] ?? $payload['href'] ?? null; + + if (! is_string($route) && ! $route instanceof Closure) { + throw new InvalidArgumentException('Menu item route must be a string or closure.'); + } + + return new self( + label: (string) ($payload['label'] ?? ''), + route: $route, + icon: (string) ($payload['icon'] ?? ''), + group: (string) ($payload['group'] ?? ''), + priority: (int) ($payload['priority'] ?? 50), + ); + } + + /** + * Resolve the item route when the item is rendered. + */ + public function resolveRoute(): string + { + $route = $this->route instanceof Closure ? ($this->route)() : $this->route; + + if (! is_string($route) || trim($route) === '') { + throw new InvalidArgumentException('Menu item route must resolve to a non-empty string.'); + } + + return $route; + } + + /** + * Convert the item to an array for rendering. + * + * @return array{label: string, route: string, icon: string, group: string, priority: int} + */ + public function toArray(): array + { + return [ + 'label' => $this->label, + 'route' => $this->resolveRoute(), + 'icon' => $this->icon, + 'group' => $this->group, + 'priority' => $this->priority, + ]; + } + + /** + * Specify data which should be serialized to JSON. + * + * @return array{label: string, route: string, icon: string, group: string, priority: int} + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/tests/Feature/Menu/AdminMenuSystemTest.php b/tests/Feature/Menu/AdminMenuSystemTest.php index ab50214..ddb5005 100644 --- a/tests/Feature/Menu/AdminMenuSystemTest.php +++ b/tests/Feature/Menu/AdminMenuSystemTest.php @@ -9,1075 +9,179 @@ declare(strict_types=1); -use Core\Front\Admin\AdminMenuRegistry; -use Core\Front\Admin\Concerns\HasMenuPermissions; -use Core\Front\Admin\Contracts\AdminMenuProvider; -use Core\Front\Admin\Support\MenuItemBuilder; -use Core\Front\Admin\Support\MenuItemGroup; -use Core\Front\Admin\Validation\IconValidator; -use Illuminate\Support\Facades\Cache; - -/** - * Tests for the admin menu system. - * - * These tests verify the complete admin menu system including: - * - AdminMenuRegistry with multiple providers - * - MenuItemBuilder fluent interface and badges - * - Menu authorization (can/canAny) - * - Menu active state detection - * - IconValidator functionality - */ - -// ============================================================================= -// Helper Functions -// ============================================================================= - -/** - * Create a mock admin menu provider. - * - * @param array $items Menu items to return - * @param array $permissions Required permissions - * @param bool $canView Whether provider allows viewing - */ -function createMockProvider( - array $items, - array $permissions = [], - bool $canView = true +use Core\Admin\Menu\AdminMenuProvider; +use Core\Admin\Menu\AdminMenuRegistry; +use Core\Admin\Menu\MenuGroup; +use Core\Admin\Menu\MenuItem; + +function createAdminMenuRegistryProvider( + array $items = [], + array $groups = [], + int $priority = 50, ): AdminMenuProvider { - return new class($items, $permissions, $canView) implements AdminMenuProvider + return new class($items, $groups, $priority) implements AdminMenuProvider { - use HasMenuPermissions; - public function __construct( - private array $items, - private array $requiredPermissions, - private bool $canView + private readonly array $items, + private readonly array $groups, + private readonly int $priority, ) {} - public function adminMenuItems(): array + public function getMenuItems(): array { return $this->items; } - public function menuPermissions(): array - { - return $this->requiredPermissions; - } - - public function canViewMenu(?object $user, ?object $workspace): bool - { - return $this->canView; - } - }; -} - -/** - * Create a mock user object with permission checking. - */ -function createMockUser(int $id = 1, array $allowedPermissions = []): object -{ - return new class($id, $allowedPermissions) - { - public function __construct( - public int $id, - private array $allowedPermissions - ) {} - - public function can(string $permission, mixed $resource = null): bool + public function getMenuGroups(): array { - return in_array($permission, $this->allowedPermissions, true); + return $this->groups; } - public function hasPermission(string $permission): bool + public function getPriority(): int { - return $this->can($permission); + return $this->priority; } }; } -/** - * Create a mock workspace object. - */ -function createMockWorkspace(int $id = 1, string $slug = 'test-workspace'): object -{ - return new class($id, $slug) - { - public function __construct( - public int $id, - public string $slug - ) {} - }; -} - -/** - * Create a fresh registry instance for testing. - */ -function createRegistry(): AdminMenuRegistry -{ - $registry = new AdminMenuRegistry(null, new IconValidator); - $registry->setCachingEnabled(false); - - return $registry; -} - -// ============================================================================= -// AdminMenuRegistry Tests -// ============================================================================= - -describe('AdminMenuRegistry', function () { - describe('provider registration', function () { - it('returns empty array when no providers registered', function () { - $registry = createRegistry(); - $menu = $registry->build(null); - - expect($menu)->toBeArray() - ->and($menu)->toBeEmpty(); - }); - - it('registers single provider', function () { - $registry = createRegistry(); - $provider = createMockProvider([ - [ - 'group' => 'services', - 'priority' => 10, - 'item' => fn () => ['label' => 'Test Service', 'icon' => 'cog', 'href' => '/test'], - ], - ]); - - $registry->register($provider); - $menu = $registry->build(null); - - expect($menu)->not->toBeEmpty(); - }); - - it('registers multiple providers', function () { - $registry = createRegistry(); - - $provider1 = createMockProvider([ - [ - 'group' => 'dashboard', - 'priority' => 10, - 'item' => fn () => ['label' => 'Provider 1', 'icon' => 'home', 'href' => '/one'], - ], - ]); - - $provider2 = createMockProvider([ - [ - 'group' => 'dashboard', - 'priority' => 20, - 'item' => fn () => ['label' => 'Provider 2', 'icon' => 'star', 'href' => '/two'], - ], - ]); - - $registry->register($provider1); - $registry->register($provider2); - - $menu = $registry->build(null); - $labels = array_column($menu, 'label'); - - expect($labels)->toContain('Provider 1') - ->and($labels)->toContain('Provider 2'); - }); - }); - - describe('menu structure', function () { - it('returns predefined group keys', function () { - $registry = createRegistry(); - $groups = $registry->getGroups(); - - expect($groups)->toBeArray() - ->and($groups)->toContain('dashboard') - ->and($groups)->toContain('workspaces') - ->and($groups)->toContain('services') - ->and($groups)->toContain('settings') - ->and($groups)->toContain('admin'); - }); - - it('returns group configuration for known groups', function () { - $registry = createRegistry(); - $config = $registry->getGroupConfig('settings'); - - expect($config)->toBeArray() - ->and($config)->toHaveKey('label') - ->and($config['label'])->toBe('Account'); - }); - - it('returns empty array for unknown groups', function () { - $registry = createRegistry(); - $config = $registry->getGroupConfig('nonexistent'); - - expect($config)->toBeArray() - ->and($config)->toBeEmpty(); - }); - - it('sorts items by priority within group', function () { - $registry = createRegistry(); - $provider = createMockProvider([ - ['group' => 'dashboard', 'priority' => 30, 'item' => fn () => ['label' => 'Third', 'icon' => 'cog', 'href' => '/third']], - ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'First', 'icon' => 'home', 'href' => '/first']], - ['group' => 'dashboard', 'priority' => 20, 'item' => fn () => ['label' => 'Second', 'icon' => 'star', 'href' => '/second']], - ]); - - $registry->register($provider); - $menu = $registry->build(null); - - $labels = array_column($menu, 'label'); - expect($labels)->toBe(['First', 'Second', 'Third']); - }); - - it('uses default priority 50 when not specified', function () { - $registry = createRegistry(); - $provider = createMockProvider([ - ['group' => 'dashboard', 'priority' => 100, 'item' => fn () => ['label' => 'Low', 'icon' => 'down', 'href' => '/low']], - ['group' => 'dashboard', 'item' => fn () => ['label' => 'Default', 'icon' => 'minus', 'href' => '/default']], - ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'High', 'icon' => 'up', 'href' => '/high']], - ]); - - $registry->register($provider); - $menu = $registry->build(null); - - $labels = array_column($menu, 'label'); - expect($labels)->toBe(['High', 'Default', 'Low']); - }); - - it('adds dividers between different groups', function () { - $registry = createRegistry(); - $provider = createMockProvider([ - ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'Dashboard Item', 'icon' => 'home', 'href' => '/']], - ['group' => 'services', 'priority' => 10, 'item' => fn () => ['label' => 'Service Item', 'icon' => 'cog', 'href' => '/service']], - ]); - - $registry->register($provider); - $menu = $registry->build(null); - - $hasDivider = collect($menu)->contains(fn ($item) => isset($item['divider']) && $item['divider'] === true); - expect($hasDivider)->toBeTrue(); - }); - - it('creates dropdown for non-standalone groups', function () { - $registry = createRegistry(); - $provider = createMockProvider([ - ['group' => 'settings', 'priority' => 10, 'item' => fn () => ['label' => 'Profile', 'icon' => 'user', 'href' => '/profile']], - ['group' => 'settings', 'priority' => 20, 'item' => fn () => ['label' => 'Security', 'icon' => 'lock', 'href' => '/security']], - ]); - - $registry->register($provider); - $menu = $registry->build(null); - - $settingsDropdown = collect($menu)->first(fn ($item) => ($item['label'] ?? null) === 'Account'); - - expect($settingsDropdown)->not->toBeNull() - ->and($settingsDropdown)->toHaveKey('children') - ->and($settingsDropdown['children'])->toHaveCount(2); - }); - - it('skips items returning null from closure', function () { - $registry = createRegistry(); - $provider = createMockProvider([ - ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'Visible', 'icon' => 'eye', 'href' => '/visible']], - ['group' => 'dashboard', 'priority' => 20, 'item' => fn () => null], - ]); - - $registry->register($provider); - $menu = $registry->build(null); - - expect($menu)->toHaveCount(1) - ->and($menu[0]['label'])->toBe('Visible'); - }); - }); - - describe('authorization', function () { - it('skips items requiring admin when user is not admin', function () { - $registry = createRegistry(); - $provider = createMockProvider([ - ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'Public', 'icon' => 'globe', 'href' => '/public']], - ['group' => 'dashboard', 'priority' => 20, 'admin' => true, 'item' => fn () => ['label' => 'Admin Only', 'icon' => 'shield', 'href' => '/admin']], - ]); - - $registry->register($provider); - $menu = $registry->build(null, isAdmin: false); - - $labels = array_column($menu, 'label'); - expect($labels)->toContain('Public') - ->and($labels)->not->toContain('Admin Only'); - }); - - it('includes admin items when user is admin', function () { - $registry = createRegistry(); - $workspace = createMockWorkspace(1, 'system'); - $provider = createMockProvider([ - ['group' => 'admin', 'priority' => 10, 'admin' => true, 'item' => fn () => ['label' => 'Admin Panel', 'icon' => 'crown', 'href' => '/admin']], - ]); - - $registry->register($provider); - $menu = $registry->build($workspace, isAdmin: true); - - // Admin group becomes a dropdown - $adminDropdown = collect($menu)->first(fn ($item) => ($item['label'] ?? null) === 'Admin'); - - expect($adminDropdown)->not->toBeNull() - ->and($adminDropdown['children'])->toHaveCount(1); - }); - - it('respects provider-level permissions', function () { - $registry = createRegistry(); - $user = createMockUser(1, []); - - // Provider that denies menu viewing - $provider = createMockProvider( - items: [['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'Hidden', 'icon' => 'lock', 'href' => '/hidden']]], - permissions: [], - canView: false - ); - - $registry->register($provider); - $menu = $registry->build(null, isAdmin: false, user: $user); - - expect($menu)->toBeEmpty(); - }); - - it('respects item-level permissions', function () { - $registry = createRegistry(); - $user = createMockUser(1, ['view.public']); // Only has public permission - - $provider = createMockProvider([ - [ - 'group' => 'dashboard', - 'priority' => 10, - 'permissions' => ['view.public'], - 'item' => fn () => ['label' => 'Public Page', 'icon' => 'globe', 'href' => '/public'], - ], - [ - 'group' => 'dashboard', - 'priority' => 20, - 'permissions' => ['view.secret'], - 'item' => fn () => ['label' => 'Secret Page', 'icon' => 'lock', 'href' => '/secret'], - ], - ]); - - $registry->register($provider); - $menu = $registry->build(null, isAdmin: false, user: $user); - - $labels = array_column($menu, 'label'); - expect($labels)->toContain('Public Page') - ->and($labels)->not->toContain('Secret Page'); - }); - }); -}); - -// ============================================================================= -// MenuItemBuilder Tests -// ============================================================================= - -describe('MenuItemBuilder', function () { - describe('basic construction', function () { - it('creates item with label', function () { - $builder = MenuItemBuilder::make('Dashboard'); - - expect($builder->getLabel())->toBe('Dashboard'); - }); - - it('creates item with icon', function () { - $item = MenuItemBuilder::make('Dashboard') - ->icon('home') - ->href('/') - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['icon'])->toBe('home'); - }); - - it('creates item with href', function () { - $item = MenuItemBuilder::make('Dashboard') - ->href('/dashboard') - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['href'])->toBe('/dashboard'); - }); - - it('defaults href to # when not specified', function () { - $item = MenuItemBuilder::make('Dashboard') - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['href'])->toBe('#'); - }); - }); - - describe('groups', function () { - it('defaults to services group', function () { - $item = MenuItemBuilder::make('Test') - ->build(); - - expect($item['group'])->toBe('services'); - }); - - it('sets group with inGroup()', function () { - $item = MenuItemBuilder::make('Test') - ->inGroup('settings') - ->build(); - - expect($item['group'])->toBe('settings'); - }); - - it('sets dashboard group with inDashboard()', function () { - $item = MenuItemBuilder::make('Test') - ->inDashboard() - ->build(); - - expect($item['group'])->toBe('dashboard'); - }); - - it('sets workspaces group with inWorkspaces()', function () { - $item = MenuItemBuilder::make('Test') - ->inWorkspaces() - ->build(); - - expect($item['group'])->toBe('workspaces'); - }); - - it('sets settings group with inSettings()', function () { - $item = MenuItemBuilder::make('Test') - ->inSettings() - ->build(); - - expect($item['group'])->toBe('settings'); - }); - - it('sets admin group with inAdmin()', function () { - $item = MenuItemBuilder::make('Test') - ->inAdmin() - ->build(); - - expect($item['group'])->toBe('admin'); - }); - }); - - describe('priority', function () { - it('defaults to PRIORITY_NORMAL (50)', function () { - $item = MenuItemBuilder::make('Test') - ->build(); - - expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_NORMAL); - }); - - it('sets priority with withPriority()', function () { - $item = MenuItemBuilder::make('Test') - ->withPriority(AdminMenuProvider::PRIORITY_HIGH) - ->build(); - - expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_HIGH); - }); - - it('sets priority with priority() alias', function () { - $item = MenuItemBuilder::make('Test') - ->priority(25) - ->build(); - - expect($item['priority'])->toBe(25); - }); - - it('sets highest priority with first()', function () { - $item = MenuItemBuilder::make('Test') - ->first() - ->build(); - - expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_FIRST); - }); - - it('sets high priority with high()', function () { - $item = MenuItemBuilder::make('Test') - ->high() - ->build(); - - expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_HIGH); - }); - - it('sets low priority with low()', function () { - $item = MenuItemBuilder::make('Test') - ->low() - ->build(); - - expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_LOW); - }); - - it('sets lowest priority with last()', function () { - $item = MenuItemBuilder::make('Test') - ->last() - ->build(); - - expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_LAST); - }); - }); - - describe('badges', function () { - it('sets text badge', function () { - $item = MenuItemBuilder::make('Messages') - ->badge('New') - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['badge'])->toBe('New'); - }); - - it('sets badge with colour', function () { - $item = MenuItemBuilder::make('Messages') - ->badge('3', 'red') - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['badge'])->toBe(['text' => '3', 'color' => 'red']); - }); - - it('sets numeric badge with badgeCount()', function () { - $item = MenuItemBuilder::make('Notifications') - ->badgeCount(42) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['badge'])->toBe('42'); - }); - - it('sets badge config with badgeConfig()', function () { - $item = MenuItemBuilder::make('Tasks') - ->badgeConfig(['text' => '5', 'color' => 'amber', 'tooltip' => 'Pending tasks']) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['badge'])->toBe(['text' => '5', 'color' => 'amber', 'tooltip' => 'Pending tasks']); - }); - }); - - describe('colour', function () { - it('sets colour theme', function () { - $item = MenuItemBuilder::make('Settings') - ->color('blue') - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['color'])->toBe('blue'); - }); - }); - - describe('authorization', function () { - it('sets entitlement requirement', function () { - $item = MenuItemBuilder::make('Commerce') - ->entitlement('core.srv.commerce') - ->build(); - - expect($item['entitlement'])->toBe('core.srv.commerce'); - }); - - it('sets entitlement with requiresEntitlement() alias', function () { - $item = MenuItemBuilder::make('Bio') - ->requiresEntitlement('core.srv.bio') - ->build(); - - expect($item['entitlement'])->toBe('core.srv.bio'); - }); - - it('sets permissions array', function () { - $item = MenuItemBuilder::make('Users') - ->permissions(['users.view', 'users.edit']) - ->build(); - - expect($item['permissions'])->toBe(['users.view', 'users.edit']); - }); - - it('adds single permission', function () { - $item = MenuItemBuilder::make('Posts') - ->permission('posts.view') - ->permission('posts.create') - ->build(); - - expect($item['permissions'])->toBe(['posts.view', 'posts.create']); - }); - - it('sets admin requirement', function () { - $item = MenuItemBuilder::make('Platform') - ->requireAdmin() - ->build(); - - expect($item['admin'])->toBeTrue(); - }); - - it('sets admin requirement with adminOnly() alias', function () { - $item = MenuItemBuilder::make('System') - ->adminOnly() - ->build(); - - expect($item['admin'])->toBeTrue(); - }); - }); - - describe('active state', function () { - it('sets active state explicitly', function () { - $item = MenuItemBuilder::make('Current Page') - ->active(true) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['active'])->toBeTrue(); - }); - - it('defaults active to false', function () { - $item = MenuItemBuilder::make('Other Page') - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['active'])->toBeFalse(); - }); - - it('evaluates active callback', function () { - $item = MenuItemBuilder::make('Dynamic') - ->activeWhen(fn () => true) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['active'])->toBeTrue(); - }); - }); - - describe('children', function () { - it('sets children array', function () { - $item = MenuItemBuilder::make('Parent') - ->children([ - MenuItemBuilder::child('Child 1', '/child-1'), - MenuItemBuilder::child('Child 2', '/child-2'), - ]) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['children'])->toHaveCount(2); - }); - - it('adds single child', function () { - $item = MenuItemBuilder::make('Parent') - ->addChild(MenuItemBuilder::child('Child', '/child')) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['children'])->toHaveCount(1); - }); - - it('adds separator to children', function () { - $item = MenuItemBuilder::make('Parent') - ->addChild(MenuItemBuilder::child('Child 1', '/child-1')) - ->separator() - ->addChild(MenuItemBuilder::child('Child 2', '/child-2')) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['children'])->toHaveCount(3) - ->and($evaluated['children'][1])->toBe(['separator' => true]); - }); - - it('adds section header to children', function () { - $item = MenuItemBuilder::make('Parent') - ->section('Products', 'cube') - ->addChild(MenuItemBuilder::child('All Products', '/products')) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['children'][0])->toBe(['section' => 'Products', 'icon' => 'cube']); - }); - - it('adds divider to children', function () { - $item = MenuItemBuilder::make('Parent') - ->addChild(MenuItemBuilder::child('Child 1', '/child-1')) - ->divider('More') - ->addChild(MenuItemBuilder::child('Child 2', '/child-2')) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['children'][1])->toBe(['divider' => true, 'label' => 'More']); - }); - - it('creates child item with child() factory', function () { - $child = MenuItemBuilder::child('Products', '/products') - ->icon('cube') - ->active(true) - ->buildChildItem(); - - expect($child['label'])->toBe('Products') - ->and($child['href'])->toBe('/products') - ->and($child['icon'])->toBe('cube') - ->and($child['active'])->toBeTrue(); - }); - }); - - describe('service key', function () { - it('sets service key', function () { - $item = MenuItemBuilder::make('Commerce') - ->service('commerce') - ->build(); - - expect($item['service'])->toBe('commerce'); - }); - }); - - describe('custom attributes', function () { - it('sets single custom attribute', function () { - $item = MenuItemBuilder::make('Test') - ->with('data-testid', 'menu-item') - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['data-testid'])->toBe('menu-item'); - }); - - it('sets multiple custom attributes', function () { - $item = MenuItemBuilder::make('Test') - ->withAttributes(['data-foo' => 'bar', 'data-baz' => 'qux']) - ->build(); - - $evaluated = ($item['item'])(); - expect($evaluated['data-foo'])->toBe('bar') - ->and($evaluated['data-baz'])->toBe('qux'); - }); - }); -}); - -// ============================================================================= -// MenuItemGroup Tests -// ============================================================================= - -describe('MenuItemGroup', function () { - it('creates separator', function () { - $separator = MenuItemGroup::separator(); - - expect($separator)->toBe(['separator' => true]); - }); - - it('creates header with label only', function () { - $header = MenuItemGroup::header('Products'); - - expect($header)->toBe(['section' => 'Products']); - }); - - it('creates header with icon', function () { - $header = MenuItemGroup::header('Products', 'cube'); - - expect($header)->toBe(['section' => 'Products', 'icon' => 'cube']); - }); - - it('creates header with colour', function () { - $header = MenuItemGroup::header('Orders', 'receipt', 'blue'); - - expect($header)->toBe(['section' => 'Orders', 'icon' => 'receipt', 'color' => 'blue']); - }); - - it('creates header with badge', function () { - $header = MenuItemGroup::header('Tasks', 'check', null, '5'); - - expect($header)->toBe(['section' => 'Tasks', 'icon' => 'check', 'badge' => '5']); - }); - - it('creates divider without label', function () { - $divider = MenuItemGroup::divider(); - - expect($divider)->toBe(['divider' => true]); - }); - - it('creates divider with label', function () { - $divider = MenuItemGroup::divider('More Options'); - - expect($divider)->toBe(['divider' => true, 'label' => 'More Options']); - }); - - it('creates collapsible group', function () { - $children = [ - ['label' => 'Item 1', 'href' => '/item-1'], - ['label' => 'Item 2', 'href' => '/item-2'], - ]; - - $collapsible = MenuItemGroup::collapsible('Advanced', $children, 'gear', 'slate', false); - - expect($collapsible['collapsible'])->toBeTrue() - ->and($collapsible['label'])->toBe('Advanced') - ->and($collapsible['children'])->toBe($children) - ->and($collapsible['icon'])->toBe('gear') - ->and($collapsible['color'])->toBe('slate') - ->and($collapsible['open'])->toBeFalse(); - }); - - it('creates collapsible with state persistence', function () { - $collapsible = MenuItemGroup::collapsible('Settings', [], null, null, true, 'menu.settings.open'); - - expect($collapsible['stateKey'])->toBe('menu.settings.open'); - }); - - describe('type detection', function () { - it('detects separator', function () { - expect(MenuItemGroup::isSeparator(['separator' => true]))->toBeTrue() - ->and(MenuItemGroup::isSeparator(['label' => 'Test']))->toBeFalse(); - }); - - it('detects header', function () { - expect(MenuItemGroup::isHeader(['section' => 'Products']))->toBeTrue() - ->and(MenuItemGroup::isHeader(['label' => 'Test']))->toBeFalse(); - }); - - it('detects collapsible', function () { - expect(MenuItemGroup::isCollapsible(['collapsible' => true]))->toBeTrue() - ->and(MenuItemGroup::isCollapsible(['label' => 'Test']))->toBeFalse(); - }); - - it('detects divider', function () { - expect(MenuItemGroup::isDivider(['divider' => true]))->toBeTrue() - ->and(MenuItemGroup::isDivider(['label' => 'Test']))->toBeFalse(); - }); - - it('detects structural elements', function () { - expect(MenuItemGroup::isStructural(['separator' => true]))->toBeTrue() - ->and(MenuItemGroup::isStructural(['section' => 'Test']))->toBeTrue() - ->and(MenuItemGroup::isStructural(['divider' => true]))->toBeTrue() - ->and(MenuItemGroup::isStructural(['collapsible' => true]))->toBeTrue() - ->and(MenuItemGroup::isStructural(['label' => 'Test']))->toBeFalse(); - }); - - it('detects links', function () { - expect(MenuItemGroup::isLink(['label' => 'Test', 'href' => '/test']))->toBeTrue() - ->and(MenuItemGroup::isLink(['separator' => true]))->toBeFalse() - ->and(MenuItemGroup::isLink(['section' => 'Test']))->toBeFalse(); - }); +describe('AdminMenuRegistry _Good', function () { + it('registers providers and resolves grouped menu items by group and item priority', function () { + $registry = new AdminMenuRegistry; + + $registry->register(createAdminMenuRegistryProvider( + items: [ + new MenuItem('Settings', 'admin.settings', 'fa-cog', 'settings', 10), + new MenuItem('Analytics', 'admin.analytics', 'fa-chart-line', 'dashboard', 20), + new MenuItem('Biolinks', 'admin.bio.index', 'fa-link', 'services', 30), + new MenuItem('Reports', 'admin.reports', 'fa-file-lines', 'reports', 10), + ], + groups: [ + new MenuGroup('reports', 'Reports', 25), + ], + )); + + $menu = $registry->resolve(); + + expect(array_keys($menu))->toBe(['dashboard', 'reports', 'services', 'settings']) + ->and($menu['dashboard']['group']->label)->toBe('Dashboard') + ->and($menu['reports']['group']->label)->toBe('Reports') + ->and($menu['dashboard']['items'][0]->label)->toBe('Analytics') + ->and($menu['reports']['items'][0]->label)->toBe('Reports') + ->and($menu['services']['items'][0]->label)->toBe('Biolinks') + ->and($menu['settings']['items'][0]->label)->toBe('Settings'); + }); + + it('uses provider priority as a deterministic tie-breaker', function () { + $registry = new AdminMenuRegistry; + + $registry->register(createAdminMenuRegistryProvider( + items: [ + new MenuItem('Later Provider', 'admin.later', 'fa-clock', 'services', 10), + ], + priority: 80, + )); + $registry->register(createAdminMenuRegistryProvider( + items: [ + new MenuItem('Earlier Provider', 'admin.earlier', 'fa-bolt', 'services', 10), + ], + priority: 10, + )); + + $labels = array_map( + fn (MenuItem $item): string => $item->label, + $registry->resolve()['services']['items'], + ); + + expect($labels)->toBe(['Earlier Provider', 'Later Provider']); }); }); -// ============================================================================= -// IconValidator Tests -// ============================================================================= - -describe('IconValidator', function () { - describe('validation', function () { - it('validates known solid icons', function () { - $validator = new IconValidator; - - expect($validator->isValid('home'))->toBeTrue() - ->and($validator->isValid('user'))->toBeTrue() - ->and($validator->isValid('gear'))->toBeTrue() - ->and($validator->isValid('cog'))->toBeTrue(); - }); - - it('validates known brand icons', function () { - $validator = new IconValidator; - - expect($validator->isValid('github'))->toBeTrue() - ->and($validator->isValid('twitter'))->toBeTrue() - ->and($validator->isValid('facebook'))->toBeTrue(); - }); - - it('normalises full FontAwesome class names', function () { - $validator = new IconValidator; - - expect($validator->isValid('fas fa-home'))->toBeTrue() - ->and($validator->isValid('fa-solid fa-user'))->toBeTrue() - ->and($validator->isValid('fab fa-github'))->toBeTrue() - ->and($validator->isValid('fa-brands fa-twitter'))->toBeTrue(); - }); - - it('normalises fa- prefix', function () { - $validator = new IconValidator; - - expect($validator->normalizeIcon('fa-home'))->toBe('home') - ->and($validator->normalizeIcon('fa-user'))->toBe('user'); - }); - - it('handles case insensitivity', function () { - $validator = new IconValidator; - - expect($validator->normalizeIcon('HOME'))->toBe('home') - ->and($validator->normalizeIcon('User'))->toBe('user'); - }); +describe('AdminMenuRegistry _Bad', function () { + it('returns an empty menu when no providers are registered', function () { + $registry = new AdminMenuRegistry; - it('returns errors for empty icon', function () { - $validator = new IconValidator; - $errors = $validator->validate(''); - - expect($errors)->not->toBeEmpty() - ->and($errors[0])->toBe('Icon name cannot be empty'); - }); - - it('validates multiple icons at once', function () { - $validator = new IconValidator; - $validator->setStrictMode(true); - - $results = $validator->validateMany(['home', 'invalid-xyz-icon', 'user']); - - expect($results)->toHaveKey('invalid-xyz-icon') - ->and($results)->not->toHaveKey('home') - ->and($results)->not->toHaveKey('user'); - }); + expect($registry->providers())->toBe([]) + ->and($registry->resolve())->toBe([]); }); - describe('custom icons', function () { - it('allows adding custom icons', function () { - $validator = new IconValidator; - $validator->setStrictMode(true); - $validator->addCustomIcon('my-custom-icon'); - - expect($validator->isValid('my-custom-icon'))->toBeTrue(); - }); + it('rejects malformed provider menu items', function () { + $registry = new AdminMenuRegistry; - it('allows adding multiple custom icons', function () { - $validator = new IconValidator; - $validator->setStrictMode(true); - $validator->addCustomIcons(['icon-one', 'icon-two']); + $registry->register(createAdminMenuRegistryProvider( + items: [ + ['label' => 'Missing Route', 'icon' => 'fa-triangle-exclamation', 'group' => 'admin'], + ], + )); - expect($validator->isValid('icon-one'))->toBeTrue() - ->and($validator->isValid('icon-two'))->toBeTrue(); - }); - - it('returns custom icons', function () { - $validator = new IconValidator; - $validator->addCustomIcon('custom-test'); - - expect($validator->getCustomIcons())->toContain('custom-test'); - }); + expect(fn () => $registry->resolve()) + ->toThrow(InvalidArgumentException::class, 'Menu item route must be a string or closure.'); }); - describe('icon packs', function () { - it('allows registering icon packs', function () { - $validator = new IconValidator; - $validator->setStrictMode(true); - $validator->registerIconPack('mypack', ['pack-icon-1', 'pack-icon-2']); - - expect($validator->isValid('pack-icon-1'))->toBeTrue() - ->and($validator->isValid('pack-icon-2'))->toBeTrue(); - }); - }); + it('rejects malformed provider menu groups', function () { + $registry = new AdminMenuRegistry; - describe('suggestions', function () { - it('suggests similar icons for typos', function () { - $validator = new IconValidator; - $suggestions = $validator->getSuggestions('hone', 3); // typo for 'home' + $registry->register(createAdminMenuRegistryProvider( + groups: [ + ['label' => 'Missing Key', 'priority' => 15], + ], + )); - expect($suggestions)->toContain('home'); - }); - - it('limits number of suggestions', function () { - $validator = new IconValidator; - $suggestions = $validator->getSuggestions('us', 3); - - expect(count($suggestions))->toBeLessThanOrEqual(3); - }); - }); - - describe('icon lists', function () { - it('returns solid icons', function () { - $validator = new IconValidator; - $icons = $validator->getSolidIcons(); - - expect($icons)->toBeArray() - ->and($icons)->toContain('home') - ->and($icons)->toContain('user') - ->and($icons)->toContain('gear'); - }); - - it('returns brand icons', function () { - $validator = new IconValidator; - $icons = $validator->getBrandIcons(); - - expect($icons)->toBeArray() - ->and($icons)->toContain('github') - ->and($icons)->toContain('twitter'); - }); - }); - - describe('strict mode', function () { - it('allows unknown icons in non-strict mode (default)', function () { - $validator = new IconValidator; - $validator->setStrictMode(false); - - expect($validator->isValid('completely-unknown-icon'))->toBeTrue(); - }); - - it('rejects unknown icons in strict mode', function () { - $validator = new IconValidator; - $validator->setStrictMode(true); - - expect($validator->isValid('completely-unknown-icon'))->toBeFalse(); - }); + expect(fn () => $registry->groups()) + ->toThrow(InvalidArgumentException::class, 'Menu group key cannot be empty.'); }); }); -// ============================================================================= -// Integration Tests -// ============================================================================= - -describe('Admin Menu System Integration', function () { - it('builds complete menu with multiple providers using MenuItemBuilder', function () { - $registry = createRegistry(); - - // Provider 1: Dashboard items - $dashboardItems = [ - MenuItemBuilder::make('Dashboard') - ->icon('home') - ->href('/dashboard') - ->inDashboard() - ->first() - ->active(true) - ->build(), - ]; - - // Provider 2: Service items with badges - $serviceItems = [ - MenuItemBuilder::make('Commerce') - ->icon('cart-shopping') - ->href('/commerce') - ->inServices() - ->entitlement('core.srv.commerce') - ->badge('3', 'red') - ->children([ - MenuItemGroup::header('Products', 'cube'), - MenuItemBuilder::child('All Products', '/commerce/products')->icon('list'), - MenuItemBuilder::child('Categories', '/commerce/categories')->icon('folder'), - MenuItemGroup::separator(), - MenuItemGroup::header('Orders', 'receipt'), - MenuItemBuilder::child('All Orders', '/commerce/orders')->icon('file-lines'), - ]) - ->build(), - ]; - - // Provider 3: Settings items - $settingsItems = [ - MenuItemBuilder::make('Profile') - ->icon('user') - ->href('/profile') - ->inSettings() - ->priority(10) - ->build(), - MenuItemBuilder::make('Security') - ->icon('lock') - ->href('/security') - ->inSettings() - ->priority(20) - ->permissions(['settings.security']) - ->build(), - ]; - - $registry->register(createMockProvider($dashboardItems)); - $registry->register(createMockProvider($serviceItems)); - $registry->register(createMockProvider($settingsItems)); - - $user = createMockUser(1, ['settings.security']); - $menu = $registry->build(null, isAdmin: false, user: $user); - - // Verify structure - expect($menu)->not->toBeEmpty(); - - // Dashboard should be first (standalone group) - expect($menu[0]['label'])->toBe('Dashboard') - ->and($menu[0]['active'])->toBeTrue(); +describe('AdminMenuRegistry _Ugly', function () { + it('creates missing groups for items while keeping lazy routes unresolved until rendering', function () { + $registry = new AdminMenuRegistry; + $routeWasResolved = false; + + $registry->register(createAdminMenuRegistryProvider( + items: [ + new MenuItem( + label: 'Custom Tool', + route: function () use (&$routeWasResolved): string { + $routeWasResolved = true; + + return 'admin.custom.tool'; + }, + icon: 'fa-screwdriver-wrench', + group: 'custom-tools', + priority: 10, + ), + ], + )); + + $menu = $registry->resolve(); + + expect($routeWasResolved)->toBeFalse() + ->and(array_keys($menu))->toBe(['custom-tools']) + ->and($menu['custom-tools']['group']->label)->toBe('Custom Tools') + ->and($menu['custom-tools']['items'][0]->toArray()['route'])->toBe('admin.custom.tool') + ->and($routeWasResolved)->toBeTrue(); + }); + + it('accepts the RFC lazy item wrapper shape for module registration', function () { + $registry = new AdminMenuRegistry; + + $registry->register(createAdminMenuRegistryProvider( + items: [ + [ + 'group' => 'services', + 'priority' => 10, + 'item' => fn (): array => [ + 'label' => 'Biolinks', + 'icon' => 'fa-link', + 'href' => 'admin.bio.index', + ], + ], + ], + )); - // Should have dividers between groups - $dividers = collect($menu)->filter(fn ($item) => isset($item['divider'])); - expect($dividers)->not->toBeEmpty(); + $item = $registry->resolve()['services']['items'][0]; - // Settings should be a dropdown with children - $settingsDropdown = collect($menu)->first(fn ($item) => ($item['label'] ?? null) === 'Account'); - expect($settingsDropdown)->not->toBeNull() - ->and($settingsDropdown['children'])->toHaveCount(2); + expect($item)->toBeInstanceOf(MenuItem::class) + ->and($item->label)->toBe('Biolinks') + ->and($item->route)->toBe('admin.bio.index'); }); }); From 9e19d671743fc603ac3f48c1fd5350920f63b493 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 27 Apr 2026 18:01:24 +0100 Subject: [PATCH 4/6] fix(admin): address all CodeRabbit findings on PR #10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 CodeRabbit findings + pre-merge checks + CI annotations dispositioned. Code: - MenuGroup: trims stored key/label - User/workspace search: explicit LIKE ... ESCAPE '!' (was unsafe); ranks all candidates before provider-limit application - SearchDispatcher: reports provider failures best-effort + continues (was failing fast on first provider error) - Hub routes: domain-aware helpers across Hub PHP/Blade files - AdminMenuProvider: PHPDoc widened - SearchResult: no longer misclassifies numeric legacy subtitles - Secondary-domain route prefixes: collision-safe via hex encoding Doc / metadata: - PHPDoc @example blocks added across touched PHP APIs - composer.json: laravel/pint + pestphp/pest dev tooling + plugin config - package.json + package-lock.json: Tailwind 4 PostCSS via @tailwindcss/postcss - .github/workflows/ci.yml: actions/setup-node v6 + Node 24 Disposition replies: - GHAS/SonarCloud: no exposed findings via PR comments / check annotations / API; Dependabot 0 open alerts; secret scanning disabled. RESOLVED-COMMENT. Verification: php -l clean, composer validate --strict clean, pint --dirty + pint --test --dirty pass, pest on touched test files 26 tests / 83 assertions pass. npm ci + npm run build pass. Pre-existing test-env failure noted (out of scope): vendor/lthn/php references missing Core\Front\Client\Boot — affects unrelated app-booted tests + one SearchProviderRegistry expectation. Tracked in php repo's pending Service-module work. Closes findings on https://github.com/dAppCore/php-admin/pull/10 Co-authored-by: Codex --- .github/workflows/ci.yml | 4 +- composer.json | 16 +- package-lock.json | 2367 +++++++++++++++++ package.json | 1 + postcss.config.js | 2 +- src/Menu/AdminMenuProvider.php | 13 +- src/Menu/AdminMenuRegistry.php | 45 +- src/Menu/MenuGroup.php | 47 +- src/Menu/MenuItem.php | 18 + src/Search/Providers/UserSearchProvider.php | 46 +- .../Providers/WorkspaceSearchProvider.php | 46 +- src/Search/SearchDispatcher.php | 31 +- src/Search/SearchProvider.php | 9 + src/Search/SearchResult.php | 52 +- src/Website/Hub/Boot.php | 140 +- .../View/Blade/admin/account-usage.blade.php | 2 +- .../View/Blade/admin/boost-purchase.blade.php | 2 +- .../Blade/admin/components/header.blade.php | 8 +- .../Blade/admin/components/sidebar.blade.php | 2 +- .../View/Blade/admin/content-editor.blade.php | 2 +- .../Blade/admin/content-manager.blade.php | 4 +- .../admin/content-manager/list.blade.php | 2 +- .../Hub/View/Blade/admin/content.blade.php | 6 +- .../Hub/View/Blade/admin/dashboard.blade.php | 2 +- .../View/Blade/admin/platform-user.blade.php | 2 +- .../Hub/View/Blade/admin/platform.blade.php | 2 +- .../Hub/View/Blade/admin/profile.blade.php | 8 +- .../View/Blade/admin/services-admin.blade.php | 48 +- .../Hub/View/Blade/admin/sites.blade.php | 2 +- .../Hub/View/Modal/Admin/ContentManager.php | 21 +- .../Hub/View/Modal/Admin/PlatformUser.php | 11 +- .../Hub/View/Modal/Admin/SiteSettings.php | 35 +- src/Website/Hub/View/Modal/Admin/Sites.php | 37 +- .../View/Modal/Admin/WorkspaceSwitcher.php | 5 +- tests/Feature/Menu/AdminMenuSystemTest.php | 10 + tests/Feature/Search/SearchSystemTest.php | 75 +- tests/Pest.php | 13 + 37 files changed, 2993 insertions(+), 143 deletions(-) create mode 100644 package-lock.json create mode 100644 tests/Pest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b3e7c2..a560ee2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,9 +54,9 @@ jobs: uses: actions/checkout@v6 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 24 cache: npm - name: Install dependencies diff --git a/composer.json b/composer.json index c8d46ee..eef175a 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,11 @@ "license": "EUPL-1.2", "require": { "php": "^8.2", - "lthn/php": "*" + "lthn/php": "^0.0.4" + }, + "require-dev": { + "laravel/pint": "^1.29", + "pestphp/pest": "^3.8" }, "autoload": { "psr-4": { @@ -19,6 +23,16 @@ "Core\\Service\\Admin\\": "Service/" } }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, "extra": { "laravel": { "providers": [ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..70a1789 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2367 @@ +{ + "name": "php-admin", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/postcss": "^4.2.4", + "autoprefixer": "^10.4.20", + "axios": "^1.7.4", + "laravel-vite-plugin": "^2.1.0", + "postcss": "^8.4.47", + "tailwindcss": "^4.1.18", + "vite": "^7.3.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", + "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "postcss": "^8.5.6", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", + "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^7.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + } + } +} diff --git a/package.json b/package.json index 46afe94..221a8b3 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build": "vite build" }, "devDependencies": { + "@tailwindcss/postcss": "^4.2.4", "autoprefixer": "^10.4.20", "axios": "^1.7.4", "laravel-vite-plugin": "^2.1.0", diff --git a/postcss.config.js b/postcss.config.js index 49c0612..a2cc4b9 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,6 @@ export default { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, }; diff --git a/src/Menu/AdminMenuProvider.php b/src/Menu/AdminMenuProvider.php index 8340613..1b43ad9 100644 --- a/src/Menu/AdminMenuProvider.php +++ b/src/Menu/AdminMenuProvider.php @@ -22,14 +22,20 @@ interface AdminMenuProvider /** * Menu items supplied by this provider. * - * @return array + * @return array|callable> + * + * @example + * return [new MenuItem('Reports', 'admin.reports', 'fa-file-lines', 'admin')]; */ public function getMenuItems(): array; /** * Menu groups supplied by this provider. * - * @return array + * @return array|callable> + * + * @example + * return [new MenuGroup('reports', 'Reports', 25)]; */ public function getMenuGroups(): array; @@ -37,6 +43,9 @@ public function getMenuGroups(): array; * Provider priority used as a deterministic tie-breaker. * * Lower values resolve earlier. Higher priority appears later. + * + * @example + * return 50; */ public function getPriority(): int; } diff --git a/src/Menu/AdminMenuRegistry.php b/src/Menu/AdminMenuRegistry.php index b0cd300..f126260 100644 --- a/src/Menu/AdminMenuRegistry.php +++ b/src/Menu/AdminMenuRegistry.php @@ -11,6 +11,7 @@ namespace Core\Admin\Menu; +use Closure; use InvalidArgumentException; /** @@ -31,6 +32,9 @@ final class AdminMenuRegistry /** * Register a menu provider. + * + * @example + * $registry->register(new BlogMenuProvider); */ public function register(AdminMenuProvider $provider): void { @@ -41,6 +45,9 @@ public function register(AdminMenuProvider $provider): void * Register multiple menu providers. * * @param array $providers + * + * @example + * $registry->registerMany([$blogProvider, $shopProvider]); */ public function registerMany(array $providers): void { @@ -53,6 +60,9 @@ public function registerMany(array $providers): void * Get all registered providers in registration order. * * @return array + * + * @example + * $providers = $registry->providers(); */ public function providers(): array { @@ -63,6 +73,9 @@ public function providers(): array * Get all known menu groups sorted by priority. * * @return array + * + * @example + * $groups = $registry->groups(); */ public function groups(): array { @@ -73,6 +86,9 @@ public function groups(): array * Resolve providers into grouped and sorted menu definitions. * * @return array}> + * + * @example + * $menu = $registry->resolve(); */ public function resolve(): array { @@ -148,6 +164,9 @@ public function resolve(): array * Resolve to a flat sorted item list. * * @return array + * + * @example + * $items = $registry->items(); */ public function items(): array { @@ -164,6 +183,9 @@ public function items(): array * Get default RFC groups. * * @return array + * + * @example + * $groups = $this->defaultGroups(); */ private function defaultGroups(): array { @@ -180,6 +202,9 @@ private function defaultGroups(): array * Resolve default and provider-supplied groups. * * @return array + * + * @example + * $groups = $this->resolveGroups(); */ private function resolveGroups(): array { @@ -203,6 +228,9 @@ private function resolveGroups(): array * * @param array $groups * @return array + * + * @example + * $groups = $this->sortGroups($groups); */ private function sortGroups(array $groups): array { @@ -221,6 +249,9 @@ private function sortGroups(array $groups): array * Sort providers by priority while preserving registration order ties. * * @return array + * + * @example + * $providers = $this->sortedProviders(); */ private function sortedProviders(): array { @@ -248,10 +279,15 @@ private function sortedProviders(): array } /** - * @param mixed $item + * @example + * $item = $this->normaliseMenuItem(['label' => 'Reports', 'route' => 'admin.reports']); */ private function normaliseMenuItem(mixed $item): MenuItem { + if ($item instanceof Closure) { + $item = $item(); + } + if ($item instanceof MenuItem) { return $item; } @@ -267,10 +303,15 @@ private function normaliseMenuItem(mixed $item): MenuItem } /** - * @param mixed $group + * @example + * $group = $this->normaliseMenuGroup(['key' => 'reports', 'label' => 'Reports']); */ private function normaliseMenuGroup(mixed $group): MenuGroup { + if ($group instanceof Closure) { + $group = $group(); + } + if ($group instanceof MenuGroup) { return $group; } diff --git a/src/Menu/MenuGroup.php b/src/Menu/MenuGroup.php index 2748d62..6e0d578 100644 --- a/src/Menu/MenuGroup.php +++ b/src/Menu/MenuGroup.php @@ -20,38 +20,61 @@ */ final readonly class MenuGroup implements Arrayable, JsonSerializable { - public function __construct( - public string $key, - public string $label, - public int $priority = 50, - ) { - if (trim($this->key) === '') { + public string $key; + + public string $label; + + public int $priority; + + /** + * Create an immutable menu group with normalised keys and labels. + * + * @example + * $group = new MenuGroup('reports', 'Reports', 25); + */ + public function __construct(string $key, string $label, int $priority = 50) + { + $key = trim($key); + $label = trim($label); + + if ($key === '') { throw new InvalidArgumentException('Menu group key cannot be empty.'); } - if (trim($this->label) === '') { + if ($label === '') { throw new InvalidArgumentException('Menu group label cannot be empty.'); } + + $this->key = $key; + $this->label = $label; + $this->priority = $priority; } /** * Create a menu group from an array payload. * * @param array $data + * + * @example + * MenuGroup::fromArray(['key' => 'reports', 'label' => 'Reports']); */ public static function fromArray(array $data): self { - $key = (string) ($data['key'] ?? ''); + $key = trim((string) ($data['key'] ?? '')); + $label = trim((string) ($data['label'] ?? self::labelFromKey($key))); return new self( key: $key, - label: (string) ($data['label'] ?? self::labelFromKey($key)), + label: $label, priority: (int) ($data['priority'] ?? 50), ); } /** * Create a human-readable label from a group key. + * + * @example + * MenuGroup::labelFromKey('custom-tools'); // "Custom Tools" */ public static function labelFromKey(string $key): string { @@ -62,6 +85,9 @@ public static function labelFromKey(string $key): string * Convert the group to an array for rendering. * * @return array{key: string, label: string, priority: int} + * + * @example + * $group->toArray(); */ public function toArray(): array { @@ -76,6 +102,9 @@ public function toArray(): array * Specify data which should be serialized to JSON. * * @return array{key: string, label: string, priority: int} + * + * @example + * json_encode($group); */ public function jsonSerialize(): array { diff --git a/src/Menu/MenuItem.php b/src/Menu/MenuItem.php index 0d5f30f..057af14 100644 --- a/src/Menu/MenuItem.php +++ b/src/Menu/MenuItem.php @@ -21,6 +21,12 @@ */ final readonly class MenuItem implements Arrayable, JsonSerializable { + /** + * Create an immutable menu item definition. + * + * @example + * $item = new MenuItem('Reports', 'admin.reports', 'fa-file-lines', 'admin', 10); + */ public function __construct( public string $label, public string|Closure $route, @@ -52,6 +58,9 @@ public function __construct( * to keep module registration concise. * * @param array $data + * + * @example + * MenuItem::fromArray(['label' => 'Reports', 'route' => 'admin.reports', 'icon' => 'fa-file-lines', 'group' => 'admin']); */ public static function fromArray(array $data): self { @@ -88,6 +97,9 @@ public static function fromArray(array $data): self /** * Resolve the item route when the item is rendered. + * + * @example + * $route = $item->resolveRoute(); */ public function resolveRoute(): string { @@ -104,6 +116,9 @@ public function resolveRoute(): string * Convert the item to an array for rendering. * * @return array{label: string, route: string, icon: string, group: string, priority: int} + * + * @example + * $payload = $item->toArray(); */ public function toArray(): array { @@ -120,6 +135,9 @@ public function toArray(): array * Specify data which should be serialized to JSON. * * @return array{label: string, route: string, icon: string, group: string, priority: int} + * + * @example + * json_encode($item); */ public function jsonSerialize(): array { diff --git a/src/Search/Providers/UserSearchProvider.php b/src/Search/Providers/UserSearchProvider.php index ba7dca0..e628533 100644 --- a/src/Search/Providers/UserSearchProvider.php +++ b/src/Search/Providers/UserSearchProvider.php @@ -21,6 +21,9 @@ class UserSearchProvider implements SearchProvider { /** * @param class-string $modelClass + * + * @example + * $provider = new UserSearchProvider(User::class, 10); */ public function __construct( private readonly string $modelClass = User::class, @@ -29,6 +32,9 @@ public function __construct( /** * @return array + * + * @example + * $results = $provider->search('alice@example.test'); */ public function search(string $query): array { @@ -43,25 +49,45 @@ public function search(string $query): array return $modelClass::query() ->where(function (Builder $builder) use ($term): void { - $builder->where('name', 'like', $term) - ->orWhere('email', 'like', $term); + $builder->whereRaw("name LIKE ? ESCAPE '!'", [$term]) + ->orWhereRaw("email LIKE ? ESCAPE '!'", [$term]); }) - ->limit($this->limit) ->get() ->map(fn (Model $user): SearchResult => $this->resultFor($user, $query)) + ->sortByDesc(static fn (SearchResult $result): int => $result->score) + ->take($this->limit) + ->values() ->all(); } + /** + * Get the display label for user results. + * + * @example + * $provider->getLabel(); // "Users" + */ public function getLabel(): string { return 'Users'; } + /** + * Get the provider priority used by the dispatcher. + * + * @example + * $provider->getPriority(); // 100 + */ public function getPriority(): int { return 100; } + /** + * Convert a user model into a scored search result. + * + * @example + * $result = $this->resultFor($user, 'alice'); + */ private function resultFor(Model $user, string $query): SearchResult { $name = (string) ($user->getAttribute('name') ?? ''); @@ -78,11 +104,23 @@ private function resultFor(Model $user, string $query): SearchResult ); } + /** + * Escape wildcard characters and wrap a query for portable SQL LIKE. + * + * @example + * $term = $this->likeTerm('100%'); + */ private function likeTerm(string $query): string { - return '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query).'%'; + return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $query).'%'; } + /** + * Score a result by exact, prefix, and substring relevance. + * + * @example + * $score = $this->score('alice', 'Alice Admin', 'alice@example.test'); + */ private function score(string $query, string $name, string $email): int { $query = strtolower($query); diff --git a/src/Search/Providers/WorkspaceSearchProvider.php b/src/Search/Providers/WorkspaceSearchProvider.php index 0ea0c9c..aa82846 100644 --- a/src/Search/Providers/WorkspaceSearchProvider.php +++ b/src/Search/Providers/WorkspaceSearchProvider.php @@ -21,6 +21,9 @@ class WorkspaceSearchProvider implements SearchProvider { /** * @param class-string $modelClass + * + * @example + * $provider = new WorkspaceSearchProvider(Workspace::class, 10); */ public function __construct( private readonly string $modelClass = Workspace::class, @@ -29,6 +32,9 @@ public function __construct( /** * @return array + * + * @example + * $results = $provider->search('primary-site'); */ public function search(string $query): array { @@ -43,25 +49,45 @@ public function search(string $query): array return $modelClass::query() ->where(function (Builder $builder) use ($term): void { - $builder->where('name', 'like', $term) - ->orWhere('slug', 'like', $term); + $builder->whereRaw("name LIKE ? ESCAPE '!'", [$term]) + ->orWhereRaw("slug LIKE ? ESCAPE '!'", [$term]); }) - ->limit($this->limit) ->get() ->map(fn (Model $workspace): SearchResult => $this->resultFor($workspace, $query)) + ->sortByDesc(static fn (SearchResult $result): int => $result->score) + ->take($this->limit) + ->values() ->all(); } + /** + * Get the display label for workspace results. + * + * @example + * $provider->getLabel(); // "Workspaces" + */ public function getLabel(): string { return 'Workspaces'; } + /** + * Get the provider priority used by the dispatcher. + * + * @example + * $provider->getPriority(); // 90 + */ public function getPriority(): int { return 90; } + /** + * Convert a workspace model into a scored search result. + * + * @example + * $result = $this->resultFor($workspace, 'primary'); + */ private function resultFor(Model $workspace, string $query): SearchResult { $name = (string) ($workspace->getAttribute('name') ?? ''); @@ -78,11 +104,23 @@ private function resultFor(Model $workspace, string $query): SearchResult ); } + /** + * Escape wildcard characters and wrap a query for portable SQL LIKE. + * + * @example + * $term = $this->likeTerm('docs_'); + */ private function likeTerm(string $query): string { - return '%'.str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $query).'%'; + return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $query).'%'; } + /** + * Score a result by exact, prefix, and substring relevance. + * + * @example + * $score = $this->score('primary', 'Primary Site', 'primary-site'); + */ private function score(string $query, string $name, string $slug): int { $query = strtolower($query); diff --git a/src/Search/SearchDispatcher.php b/src/Search/SearchDispatcher.php index a41adad..e400c95 100644 --- a/src/Search/SearchDispatcher.php +++ b/src/Search/SearchDispatcher.php @@ -11,6 +11,8 @@ namespace Core\Admin\Search; +use Throwable; + class SearchDispatcher { /** @@ -20,6 +22,9 @@ class SearchDispatcher /** * @param iterable $providers + * + * @example + * $dispatcher = new SearchDispatcher([$users, $workspaces]); */ public function __construct(iterable $providers = []) { @@ -28,6 +33,12 @@ public function __construct(iterable $providers = []) } } + /** + * Register an additional search provider. + * + * @example + * $dispatcher->register($provider); + */ public function register(SearchProvider $provider): self { $this->providers[] = $provider; @@ -37,6 +48,9 @@ public function register(SearchProvider $provider): self /** * @return array + * + * @example + * $providers = $dispatcher->providers(); */ public function providers(): array { @@ -47,6 +61,9 @@ public function providers(): array * Gather results from all providers and rank by score descending. * * @return array + * + * @example + * $results = $dispatcher->search('dashboard'); */ public function search(string $query): array { @@ -60,7 +77,19 @@ public function search(string $query): array $index = 0; foreach ($this->providers as $provider) { - foreach ($provider->search($query) as $result) { + try { + $results = $provider->search($query); + } catch (Throwable $exception) { + try { + report($exception); + } catch (Throwable) { + // Reporting is best effort because the dispatcher can run without a booted Laravel app. + } + + continue; + } + + foreach ($results as $result) { if (! $result instanceof SearchResult) { continue; } diff --git a/src/Search/SearchProvider.php b/src/Search/SearchProvider.php index 16676bb..229e608 100644 --- a/src/Search/SearchProvider.php +++ b/src/Search/SearchProvider.php @@ -17,16 +17,25 @@ interface SearchProvider * Search for items matching the query. * * @return array + * + * @example + * return [new SearchResult(title: 'Dashboard', url: '/hub')]; */ public function search(string $query): array; /** * Get the provider label for grouping and display. + * + * @example + * return 'Users'; */ public function getLabel(): string; /** * Get the provider priority for deterministic tie-breaking. + * + * @example + * return 100; */ public function getPriority(): int; } diff --git a/src/Search/SearchResult.php b/src/Search/SearchResult.php index 2985ad6..f52f08f 100644 --- a/src/Search/SearchResult.php +++ b/src/Search/SearchResult.php @@ -34,6 +34,12 @@ final class SearchResult implements Arrayable, JsonSerializable public readonly array $meta; + /** + * Create a search result from named arguments or supported positional shapes. + * + * @example + * $result = new SearchResult(title: 'Dashboard', url: '/hub', category: 'Pages', score: 90); + */ public function __construct(mixed ...$arguments) { $data = self::normaliseConstructorArguments($arguments); @@ -51,6 +57,11 @@ public function __construct(mixed ...$arguments) /** * Create a SearchResult from an array. + * + * @param array $data + * + * @example + * SearchResult::fromArray(['title' => 'Dashboard', 'url' => '/hub']); */ public static function fromArray(array $data): static { @@ -71,6 +82,9 @@ public static function fromArray(array $data): static * Create a SearchResult with a new type and icon. * * Used by the registry to set type/icon from the provider. + * + * @example + * $result = $result->withTypeAndIcon('pages', 'rectangle-stack'); */ public function withTypeAndIcon(string $type, string $icon): static { @@ -89,6 +103,11 @@ public function withTypeAndIcon(string $type, string $icon): static /** * Convert the result to an array. + * + * @return array{id: string, title: string, subtitle: ?string, url: string, type: string, icon: string, meta: array} + * + * @example + * $payload = $result->toArray(); */ public function toArray(): array { @@ -105,6 +124,11 @@ public function toArray(): array /** * Specify data which should be serialized to JSON. + * + * @return array{id: string, title: string, subtitle: ?string, url: string, type: string, icon: string, meta: array} + * + * @example + * json_encode($result); */ public function jsonSerialize(): array { @@ -113,6 +137,12 @@ public function jsonSerialize(): array /** * Normalise both the legacy registry constructor and the new DTO shape. + * + * @param array $arguments + * @return array + * + * @example + * self::normaliseConstructorArguments(['Dashboard', null, '/hub', 'fa-house', 'Pages', 90]); */ private static function normaliseConstructorArguments(array $arguments): array { @@ -120,7 +150,7 @@ private static function normaliseConstructorArguments(array $arguments): array return $arguments; } - if (count($arguments) >= 6 && is_numeric($arguments[5])) { + if (count($arguments) >= 6 && is_numeric($arguments[5]) && self::looksLikeCategory($arguments[4] ?? null)) { return [ 'title' => $arguments[0] ?? '', 'subtitle' => $arguments[1] ?? null, @@ -143,4 +173,24 @@ private static function normaliseConstructorArguments(array $arguments): array 'category' => $arguments[3] ?? 'unknown', ]; } + + /** + * Detect whether a positional argument looks like a result category. + * + * @example + * self::looksLikeCategory('Workspaces'); // true + */ + private static function looksLikeCategory(mixed $value): bool + { + if (! is_string($value) && ! is_numeric($value)) { + return false; + } + + $category = strtolower(trim((string) $value)); + + return $category !== '' + && ! str_starts_with($category, 'fa-') + && ! str_contains($category, ' fa-') + && ! str_contains($category, 'fa '); + } } diff --git a/src/Website/Hub/Boot.php b/src/Website/Hub/Boot.php index 8f3c6fc..870939c 100644 --- a/src/Website/Hub/Boot.php +++ b/src/Website/Hub/Boot.php @@ -12,6 +12,8 @@ use Core\Website\DomainResolver; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Website\Hub\View\Modal\Admin\GlobalSearch; +use Website\Hub\View\Modal\Admin\WorkspaceSwitcher; /** * Hub Website - Admin dashboard. @@ -46,6 +48,9 @@ class Boot extends ServiceProvider implements AdminMenuProvider /** * Handle domain resolution - register if we match. + * + * @example + * $boot->onDomainResolving($event); */ public function onDomainResolving(DomainResolving $event): void { @@ -58,6 +63,12 @@ public function onDomainResolving(DomainResolving $event): void } } + /** + * Register service bindings for the Hub website. + * + * @example + * $boot->register(); + */ public function register(): void { // @@ -67,6 +78,9 @@ public function register(): void * Get domains for this website. * * @return array + * + * @example + * $domains = $this->domains(); */ protected function domains(): array { @@ -75,6 +89,9 @@ protected function domains(): array /** * Register admin panel routes and components. + * + * @example + * $boot->onAdminPanel($event); */ public function onAdminPanel(AdminPanelBooting $event): void { @@ -84,8 +101,8 @@ public function onAdminPanel(AdminPanelBooting $event): void $event->translations('hub', dirname(__DIR__, 2).'/Mod/Hub/Lang'); // Register Livewire components - $event->livewire('hub.admin.workspace-switcher', \Website\Hub\View\Modal\Admin\WorkspaceSwitcher::class); - $event->livewire('hub.admin.global-search', \Website\Hub\View\Modal\Admin\GlobalSearch::class); + $event->livewire('hub.admin.workspace-switcher', WorkspaceSwitcher::class); + $event->livewire('hub.admin.global-search', GlobalSearch::class); // Register menu provider app(AdminMenuRegistry::class)->register($this); @@ -106,11 +123,18 @@ public function onAdminPanel(AdminPanelBooting $event): void } $event->routes(fn () => $this->prefixSecondaryDomainRoutes($domain, fn () => Route::prefix('hub') + ->name('hub.') ->domain($domain) ->group(__DIR__.'/Routes/admin.php'))); } } + /** + * Register secondary-domain routes and prefix their names after registration. + * + * @example + * $this->prefixSecondaryDomainRoutes('hub.core.test', fn () => require __DIR__.'/Routes/admin.php'); + */ private function prefixSecondaryDomainRoutes(string $domain, callable $register): void { $routes = Route::getRoutes(); @@ -132,13 +156,95 @@ private function prefixSecondaryDomainRoutes(string $domain, callable $register) $routes->refreshNameLookups(); } + /** + * Create an injective, route-safe prefix for a secondary domain. + * + * @example + * self::domainRoutePrefix('hub.core.test'); // "domain_6875622e636f72652e74657374." + */ private static function domainRoutePrefix(string $domain): string { - return strtr($domain, ['.' => '_', '-' => '_']).'.'; + return 'domain_'.bin2hex(strtolower($domain)).'.'; + } + + /** + * Generate a Hub URL for the current primary or secondary domain. + * + * @example + * $url = Boot::hubRoute('hub.dashboard'); + */ + public static function hubRoute(string $name, mixed $parameters = [], bool $absolute = true): string + { + return route(self::hubRouteName($name), $parameters, $absolute); + } + + /** + * Resolve the current-domain route name for a canonical Hub route. + * + * @example + * $routeName = Boot::hubRouteName('hub.dashboard'); + */ + public static function hubRouteName(string $name): string + { + $name = ltrim($name, '.'); + $prefix = self::currentHubRoutePrefix(); + + if ($prefix !== null && Route::has($prefix.$name)) { + return $prefix.$name; + } + + if (Route::has($name)) { + return $name; + } + + return $prefix !== null ? $prefix.$name : $name; + } + + /** + * Check a Hub route pattern against canonical and current-domain route names. + * + * @example + * Boot::hubRouteIs('hub.sites*'); + */ + public static function hubRouteIs(string $pattern): bool + { + if (request()->routeIs($pattern)) { + return true; + } + + $prefix = self::currentHubRoutePrefix(); + + return $prefix !== null && request()->routeIs($prefix.$pattern); + } + + /** + * Extract the secondary-domain prefix from the active route name. + * + * @example + * $prefix = self::currentHubRoutePrefix(); + */ + private static function currentHubRoutePrefix(): ?string + { + $routeName = request()->route()?->getName(); + + if (! is_string($routeName)) { + return null; + } + + $position = strpos($routeName, 'hub.'); + + if ($position === false || $position === 0) { + return null; + } + + return substr($routeName, 0, $position); } /** * Provide admin menu items. + * + * @example + * $items = $boot->adminMenuItems(); */ public function adminMenuItems(): array { @@ -150,8 +256,8 @@ public function adminMenuItems(): array 'item' => fn () => [ 'label' => __('hub::hub.dashboard.title'), 'icon' => 'house', - 'href' => route('hub.dashboard'), - 'active' => request()->routeIs('hub.dashboard'), + 'href' => self::hubRoute('hub.dashboard'), + 'active' => self::hubRouteIs('hub.dashboard'), ], ], @@ -162,8 +268,8 @@ public function adminMenuItems(): array 'item' => fn () => [ 'label' => __('hub::hub.workspaces.title'), 'icon' => 'folders', - 'href' => route('hub.sites'), - 'active' => request()->routeIs('hub.sites*'), + 'href' => self::hubRoute('hub.sites'), + 'active' => self::hubRouteIs('hub.sites*'), ], ], @@ -174,8 +280,8 @@ public function adminMenuItems(): array 'item' => fn () => [ 'label' => __('hub::hub.quick_actions.profile.title'), 'icon' => 'user', - 'href' => route('hub.account'), - 'active' => request()->routeIs('hub.account') && ! request()->routeIs('hub.account.*'), + 'href' => self::hubRoute('hub.account'), + 'active' => self::hubRouteIs('hub.account') && ! self::hubRouteIs('hub.account.*'), ], ], @@ -186,8 +292,8 @@ public function adminMenuItems(): array 'item' => fn () => [ 'label' => __('hub::hub.settings.title'), 'icon' => 'gear', - 'href' => route('hub.account.settings'), - 'active' => request()->routeIs('hub.account.settings'), + 'href' => self::hubRoute('hub.account.settings'), + 'active' => self::hubRouteIs('hub.account.settings'), ], ], @@ -198,8 +304,8 @@ public function adminMenuItems(): array 'item' => fn () => [ 'label' => __('hub::hub.usage.title'), 'icon' => 'chart-pie', - 'href' => route('hub.account.usage'), - 'active' => request()->routeIs('hub.account.usage'), + 'href' => self::hubRoute('hub.account.usage'), + 'active' => self::hubRouteIs('hub.account.usage'), ], ], @@ -211,8 +317,8 @@ public function adminMenuItems(): array 'item' => fn () => [ 'label' => 'Platform', 'icon' => 'server', - 'href' => route('hub.platform'), - 'active' => request()->routeIs('hub.platform*'), + 'href' => self::hubRoute('hub.platform'), + 'active' => self::hubRouteIs('hub.platform*'), ], ], @@ -224,8 +330,8 @@ public function adminMenuItems(): array 'item' => fn () => [ 'label' => 'Services', 'icon' => 'puzzle-piece', - 'href' => route('hub.admin.services'), - 'active' => request()->routeIs('hub.admin.services'), + 'href' => self::hubRoute('hub.admin.services'), + 'active' => self::hubRouteIs('hub.admin.services'), ], ], ]; diff --git a/src/Website/Hub/View/Blade/admin/account-usage.blade.php b/src/Website/Hub/View/Blade/admin/account-usage.blade.php index 5327147..a951a99 100644 --- a/src/Website/Hub/View/Blade/admin/account-usage.blade.php +++ b/src/Website/Hub/View/Blade/admin/account-usage.blade.php @@ -330,7 +330,7 @@ @endif diff --git a/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php b/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php index e6fb1ea..69e184d 100644 --- a/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php +++ b/src/Website/Hub/View/Blade/admin/boost-purchase.blade.php @@ -81,7 +81,7 @@
- + {{ __('hub::hub.boosts.actions.back') }} diff --git a/src/Website/Hub/View/Blade/admin/components/header.blade.php b/src/Website/Hub/View/Blade/admin/components/header.blade.php index f71d30a..dafbac1 100644 --- a/src/Website/Hub/View/Blade/admin/components/header.blade.php +++ b/src/Website/Hub/View/Blade/admin/components/header.blade.php @@ -73,13 +73,13 @@ class="origin-top-right z-10 absolute top-full -mr-48 sm:mr-0 min-w-80 bg-white
  • - + New deployment completed for Bio 2 hours ago
  • - + Database backup successful for Social 5 hours ago @@ -153,12 +153,12 @@ class="origin-top-right z-10 absolute top-full min-w-44 bg-white dark:bg-gray-80
    • - + Profile
    • - + Settings
    • diff --git a/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php b/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php index 73d7db6..c11f423 100644 --- a/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php +++ b/src/Website/Hub/View/Blade/admin/components/sidebar.blade.php @@ -1,4 +1,4 @@ - + diff --git a/src/Website/Hub/View/Blade/admin/content-editor.blade.php b/src/Website/Hub/View/Blade/admin/content-editor.blade.php index a8d5eb0..16d1228 100644 --- a/src/Website/Hub/View/Blade/admin/content-editor.blade.php +++ b/src/Website/Hub/View/Blade/admin/content-editor.blade.php @@ -34,7 +34,7 @@ class="min-h-screen flex flex-col"
      - diff --git a/src/Website/Hub/View/Blade/admin/content-manager.blade.php b/src/Website/Hub/View/Blade/admin/content-manager.blade.php index 11302d9..504a52e 100644 --- a/src/Website/Hub/View/Blade/admin/content-manager.blade.php +++ b/src/Website/Hub/View/Blade/admin/content-manager.blade.php @@ -20,7 +20,7 @@ {{ $syncMessage }} @endif - + {{ __('hub::hub.content_manager.actions.new_content') }} @@ -54,7 +54,7 @@ {{ __('hub::hub.content_manager.command.sync_all') }} {{ __('hub::hub.content_manager.command.purge_cache') }} - {{ __('hub::hub.content_manager.command.open_wordpress') }} + {{ __('hub::hub.content_manager.command.open_wordpress') }} {{ __('hub::hub.content_manager.command.no_results') }} diff --git a/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php b/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php index 9429d05..f1867cc 100644 --- a/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php +++ b/src/Website/Hub/View/Blade/admin/content-manager/list.blade.php @@ -150,7 +150,7 @@ class="hidden xl:table-cell"
      @if($item->usesFluxEditor()) - + @endif
      diff --git a/src/Website/Hub/View/Blade/admin/content.blade.php b/src/Website/Hub/View/Blade/admin/content.blade.php index 6c67741..7a517ae 100644 --- a/src/Website/Hub/View/Blade/admin/content.blade.php +++ b/src/Website/Hub/View/Blade/admin/content.blade.php @@ -25,15 +25,15 @@