diff --git a/app/Models/MenuItem.php b/app/Models/MenuItem.php new file mode 100644 index 000000000..20a516b56 --- /dev/null +++ b/app/Models/MenuItem.php @@ -0,0 +1,100 @@ + 'array', + 'route_params' => 'array', + 'open_in_new_tab' => 'bool', + 'is_active' => 'bool', + 'sort_order' => 'int', + ]; + + public $sortable = [ + 'order_column_name' => 'sort_order', + 'sort_when_creating' => true, + 'sort_on_has_many' => true, + ]; + + public function section(): BelongsTo + { + return $this->belongsTo(MenuSection::class, 'menu_section_id'); + } + + public function resolvedLabel(?string $locale = null): string + { + $locale = $locale ?: app()->getLocale(); + + $override = $this->label_overrides[$locale] ?? null; + if (is_string($override) && trim($override) !== '') { + return $override; + } + + if (is_string($this->label_key) && $this->label_key !== '') { + return __($this->label_key); + } + + return (string) ($this->label ?? ''); + } + + public function resolvedHref(): ?string + { + if (is_string($this->route_name) && $this->route_name !== '') { + $params = is_array($this->route_params) ? $this->route_params : []; + + if (Route::has($this->route_name)) { + return route($this->route_name, $params); + } + } + + if (is_string($this->url) && $this->url !== '') { + return $this->url; + } + + return null; + } + + protected static function booted(): void + { + static::saved(function (self $item) { + $location = $item->section?->location; + if ($location) { + Cache::forget("menus.location.{$location}.v1"); + } + }); + + static::deleted(function (self $item) { + $location = $item->section?->location; + if ($location) { + Cache::forget("menus.location.{$location}.v1"); + } + }); + } +} + diff --git a/app/Models/MenuSection.php b/app/Models/MenuSection.php new file mode 100644 index 000000000..3044bcf33 --- /dev/null +++ b/app/Models/MenuSection.php @@ -0,0 +1,52 @@ + 'bool', + 'sort_order' => 'int', + ]; + + public $sortable = [ + 'order_column_name' => 'sort_order', + 'sort_when_creating' => true, + ]; + + public function items(): HasMany + { + return $this->hasMany(MenuItem::class, 'menu_section_id'); + } + + protected static function booted(): void + { + static::saved(function (self $section) { + Cache::forget("menus.location.{$section->location}.v1"); + }); + + static::deleted(function (self $section) { + Cache::forget("menus.location.{$section->location}.v1"); + }); + } +} + diff --git a/app/Nova/MenuItem.php b/app/Nova/MenuItem.php new file mode 100644 index 000000000..bc7063d0d --- /dev/null +++ b/app/Nova/MenuItem.php @@ -0,0 +1,101 @@ +resource?->resolvedLabel() ?: "Item #{$this->id}"; + $href = $this->resource?->resolvedHref(); + $hrefPart = $href ? " · {$href}" : ''; + + return $label.$hrefPart; + } + + public function fields(Request $request): array + { + return [ + ID::make()->sortable(), + + BelongsTo::make('Section', 'section', MenuSection::class) + ->sortable() + ->rules('required'), + + Text::make('Label Key', 'label_key') + ->nullable() + ->rules('nullable', 'max:255') + ->help('Preferred. Example: menu.webinars'), + + Text::make('Label (literal)', 'label') + ->nullable() + ->rules('nullable', 'max:255') + ->help('Used only if Label Key is empty (and no locale override exists).'), + + KeyValue::make('Label Overrides', 'label_overrides') + ->keyLabel('Locale') + ->valueLabel('Label') + ->nullable() + ->rules('nullable') + ->help('Optional per-locale labels. Example key: en, fr, de'), + + Text::make('Route Name', 'route_name') + ->nullable() + ->rules('nullable', 'max:255') + ->help('Preferred for internal links, e.g. educational-resources'), + + Code::make('Route Params', 'route_params') + ->json() + ->nullable() + ->rules('nullable') + ->help('Optional JSON object, e.g. {"slug":"xyz"}'), + + Text::make('URL', 'url') + ->nullable() + ->rules('nullable', 'max:2048') + ->help('Use for external links or hardcoded internal paths like /podcasts'), + + Boolean::make('Open in new tab', 'open_in_new_tab')->default(false), + Boolean::make('Active', 'is_active')->default(true), + + Text::make('Preview', function () { + $label = $this->resource?->resolvedLabel() ?: ''; + $href = $this->resource?->resolvedHref() ?: ''; + return trim($label.' '.$href); + })->onlyOnDetail(), + ]; + } + + public static function indexQuery(NovaRequest $request, $query) + { + return $query->orderBy('sort_order')->orderBy('id'); + } +} + diff --git a/app/Nova/MenuSection.php b/app/Nova/MenuSection.php new file mode 100644 index 000000000..fab4fccff --- /dev/null +++ b/app/Nova/MenuSection.php @@ -0,0 +1,91 @@ +location ?? ''); + $column = (string) ($this->column ?? ''); + $title = $this->title_key ? __((string) $this->title_key) : (string) ($this->title ?? ''); + $title = trim($title) !== '' ? $title : "Section #{$this->id}"; + + return trim("{$location} · {$column} · {$title}"); + } + + public function fields(Request $request): array + { + return [ + ID::make()->sortable(), + + Text::make('Location', 'location') + ->rules('required', 'max:255') + ->help('Example: resources_dropdown'), + + Select::make('Column', 'column') + ->options([ + 'left' => 'Left', + 'right' => 'Right', + ]) + ->displayUsingLabels() + ->rules('required'), + + Text::make('Title Key', 'title_key') + ->nullable() + ->rules('nullable', 'max:255') + ->help('Preferred. Example: menu.learn_to_code'), + + Text::make('Title (literal)', 'title') + ->nullable() + ->rules('nullable', 'max:255') + ->help('Used only if Title Key is empty.'), + + Boolean::make('Active', 'is_active')->default(true), + + HasMany::make('Items', 'items', MenuItem::class), + ]; + } + + public static function indexQuery(NovaRequest $request, $query) + { + return $query->orderBy('sort_order')->orderBy('id'); + } +} + diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5c08fdf15..2061007d5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,6 +20,7 @@ use App\Services\Support\Gmail\GmailConnector; use App\Services\Support\Gmail\GoogleGmailConnector; use App\Services\Support\Gmail\NullGmailConnector; +use App\Services\Menu\MenuRepository; class AppServiceProvider extends ServiceProvider { @@ -42,6 +43,12 @@ public function boot(): void View::share('locales', config('app.locales')); Carbon::setLocale('app.locale'); + + View::composer('layout.menu', function ($view) { + $menuRepository = app(MenuRepository::class); + $view->with('resourcesDropdownSections', $menuRepository->sectionsForLocation('resources_dropdown')); + }); + \View::composer( ['event.add', 'event.search', 'event.edit'], function ($view) { diff --git a/app/Providers/NovaServiceProvider.php b/app/Providers/NovaServiceProvider.php index a4bcc868b..50354956f 100644 --- a/app/Providers/NovaServiceProvider.php +++ b/app/Providers/NovaServiceProvider.php @@ -14,6 +14,7 @@ use App\Nova\SupportCaseMessage as SupportCaseMessageNova; use App\Nova\SupportGmailCursor as SupportGmailCursorNova; use App\Nova\TrainingResource as TrainingResourceNova; +use App\Nova\MenuSection as MenuSectionNova; use App\Nova\Metrics\UsersPerDay; use Illuminate\Support\Facades\Gate; use Laravel\Nova\Menu\MenuSection; @@ -39,6 +40,7 @@ public function boot(): void SupportApprovalNova::class, SupportCaseMessageNova::class, SupportGmailCursorNova::class, + MenuSectionNova::class, ]); // Ensure dashboards are registered at boot so /nova/dashboards/main is always available diff --git a/app/Services/Menu/MenuRepository.php b/app/Services/Menu/MenuRepository.php new file mode 100644 index 000000000..e8f102e36 --- /dev/null +++ b/app/Services/Menu/MenuRepository.php @@ -0,0 +1,41 @@ + + */ + public function sectionsForLocation(string $location): Collection + { + if (! Schema::hasTable('menu_sections') || ! Schema::hasTable('menu_items')) { + return collect(); + } + + return Cache::remember( + "menus.location.{$location}.v1", + now()->addHour(), + function () use ($location) { + return MenuSection::query() + ->where('location', $location) + ->where('is_active', true) + ->orderBy('sort_order') + ->with([ + 'items' => function ($query) { + $query + ->where('is_active', true) + ->orderBy('sort_order'); + }, + ]) + ->get(); + } + ); + } +} + diff --git a/composer.json b/composer.json index fed81b37f..e4fab327d 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "livewire/livewire": "^3.5", "maatwebsite/excel": "^3.1", "martinlindhe/laravel-vue-i18n-generator": "dev-l10", + "outl1ne/nova-sortable": "^3.4.7", "predis/predis": "^2.2", "rappasoft/laravel-livewire-tables": "^3.3", "sentry/sentry-laravel": "^4.7", diff --git a/composer.lock b/composer.lock index 45dbda80a..03a692d3b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "49afaf81a2b6b3b808eb3b43f0a5c29c", + "content-hash": "6e41d019607af182b85e90e6db98b332", "packages": [ { "name": "archtechx/enums", @@ -5579,6 +5579,111 @@ ], "time": "2025-09-03T16:03:54+00:00" }, + { + "name": "outl1ne/nova-sortable", + "version": "3.4.7", + "source": { + "type": "git", + "url": "https://github.com/outl1ne/nova-sortable.git", + "reference": "e17d57b6da863adf0e0a7447fc5723d51dacb851" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/outl1ne/nova-sortable/zipball/e17d57b6da863adf0e0a7447fc5723d51dacb851", + "reference": "e17d57b6da863adf0e0a7447fc5723d51dacb851", + "shasum": "" + }, + "require": { + "laravel/nova": "^4.24.0", + "outl1ne/nova-translations-loader": "^5.0", + "php": ">=8.0", + "spatie/eloquent-sortable": "^3.10.0|^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Outl1ne\\NovaSortable\\ToolServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Outl1ne\\NovaSortable\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "This Laravel Nova package allows you to reorder models in a Nova resource's index view using drag & drop.", + "keywords": [ + "eloquent-sortable", + "laravel", + "nova", + "optimistdigital", + "outl1ne" + ], + "support": { + "issues": "https://github.com/outl1ne/nova-sortable/issues", + "source": "https://github.com/outl1ne/nova-sortable/tree/3.4.7" + }, + "funding": [ + { + "url": "https://github.com/outl1ne", + "type": "github" + } + ], + "time": "2023-11-27T13:40:15+00:00" + }, + { + "name": "outl1ne/nova-translations-loader", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/outl1ne/nova-translations-loader.git", + "reference": "87be6da40633e0cd0b9276cf494f0ca8954eccc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/outl1ne/nova-translations-loader/zipball/87be6da40633e0cd0b9276cf494f0ca8954eccc0", + "reference": "87be6da40633e0cd0b9276cf494f0ca8954eccc0", + "shasum": "" + }, + "require": { + "laravel/nova": "^4.0|^5.0", + "php": ">=8.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": [], + "providers": [] + } + }, + "autoload": { + "psr-4": { + "Outl1ne\\NovaTranslationsLoader\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "This Laravel Nova package helps developers load translations into their packages.", + "keywords": [ + "laravel", + "nova", + "optimistdigital", + "outl1ne", + "translations" + ], + "support": { + "issues": "https://github.com/outl1ne/nova-translations-loader/issues", + "source": "https://github.com/outl1ne/nova-translations-loader/tree/5.0.3" + }, + "time": "2024-10-22T08:19:08+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -7325,6 +7430,80 @@ ], "time": "2025-11-24T15:27:35+00:00" }, + { + "name": "spatie/eloquent-sortable", + "version": "4.5.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/eloquent-sortable.git", + "reference": "c1c4f3a66cd41eb7458783c8a4c8e5d7924a9f20" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/eloquent-sortable/zipball/c1c4f3a66cd41eb7458783c8a4c8e5d7924a9f20", + "reference": "c1c4f3a66cd41eb7458783c8a4c8e5d7924a9f20", + "shasum": "" + }, + "require": { + "illuminate/database": "^9.31|^10.0|^11.0|^12.0", + "illuminate/support": "^9.31|^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.63|^3.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.9" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.5|^10.0|^11.5.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\EloquentSortable\\EloquentSortableServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\EloquentSortable\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be" + } + ], + "description": "Sortable behaviour for eloquent models", + "homepage": "https://github.com/spatie/eloquent-sortable", + "keywords": [ + "behaviour", + "eloquent", + "laravel", + "model", + "sort", + "sortable" + ], + "support": { + "issues": "https://github.com/spatie/eloquent-sortable/issues", + "source": "https://github.com/spatie/eloquent-sortable/tree/4.5.2" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-08-25T11:46:57+00:00" + }, { "name": "spatie/error-solutions", "version": "1.1.3", diff --git a/config/menus.php b/config/menus.php new file mode 100644 index 000000000..de78ee93c --- /dev/null +++ b/config/menus.php @@ -0,0 +1,12 @@ + env('RESOURCES_DROPDOWN_USE_NOVA', true), +]; + diff --git a/database/migrations/2026_04_09_120000_create_menu_sections_table.php b/database/migrations/2026_04_09_120000_create_menu_sections_table.php new file mode 100644 index 000000000..f273b5e58 --- /dev/null +++ b/database/migrations/2026_04_09_120000_create_menu_sections_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('location')->index(); // e.g. resources_dropdown + $table->string('column')->default('left'); // left|right + $table->string('title')->nullable(); // optional literal title + $table->string('title_key')->nullable(); // optional translation key (preferred) + $table->unsignedInteger('sort_order')->default(0)->index(); + $table->boolean('is_active')->default(true)->index(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('menu_sections'); + } +}; + diff --git a/database/migrations/2026_04_09_120010_create_menu_items_table.php b/database/migrations/2026_04_09_120010_create_menu_items_table.php new file mode 100644 index 000000000..8b1aea9c1 --- /dev/null +++ b/database/migrations/2026_04_09_120010_create_menu_items_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('menu_section_id')->constrained('menu_sections')->cascadeOnDelete(); + + $table->string('label')->nullable(); // optional literal label + $table->string('label_key')->nullable(); // optional translation key (preferred) + $table->json('label_overrides')->nullable(); // { "en": "Custom", "fr": "..." } + + $table->string('url')->nullable(); // for external or internal hard links + $table->string('route_name')->nullable(); // preferred for internal links + $table->json('route_params')->nullable(); + + $table->boolean('open_in_new_tab')->default(false); + $table->boolean('is_active')->default(true)->index(); + $table->unsignedInteger('sort_order')->default(0)->index(); + + $table->timestamps(); + + $table->index(['menu_section_id', 'sort_order']); + }); + } + + public function down(): void + { + Schema::dropIfExists('menu_items'); + } +}; + diff --git a/resources/views/layout/menu.blade.php b/resources/views/layout/menu.blade.php index 9108a6364..9613fb5c7 100644 --- a/resources/views/layout/menu.blade.php +++ b/resources/views/layout/menu.blade.php @@ -104,32 +104,108 @@ class="cookweek-link hover-underline !text-[#1C4DA1] !text-[16px] cursor-pointer
  • -
    - -
    @lang('menu.coding@home')
    -
    @lang('menu.podcasts')
    -
    @lang('menu.online-courses')
    -
    @lang('menu.training')
    -
    @lang('menu.learn')
    -
    @lang('menu.toolkits')
    -
    @lang('menu.webinars')
    -
    @lang('menu.girls_in_digital')
    -
    @lang('menu.careers_in_digital')
    -
    @lang('menu.matchmaking_toolkit')
    -
    @lang('menu.future_ready_csr')
    -
    -
    - -
    @lang('menu.challenges')
    -
    @lang('menu.hackathons')
    -
    @lang('snippets.dance.menu')
    -
    @lang('menu.treasure-hunt')
    -
    - Minecraft Education -
    -
    + @php + $resourcesDropdownSections = $resourcesDropdownSections ?? collect(); + $resourcesDropdownLeft = $resourcesDropdownSections->filter(fn ($s) => ($s->column ?? null) === 'left'); + $resourcesDropdownRight = $resourcesDropdownSections->filter(fn ($s) => ($s->column ?? null) === 'right'); + $resourcesDropdownHasAnyItems = $resourcesDropdownSections->contains(fn ($s) => ($s->items?->count() ?? 0) > 0); + @endphp + + @if(config('menus.resources_dropdown_use_nova', true) && $resourcesDropdownSections->isNotEmpty() && $resourcesDropdownHasAnyItems) +
    + @foreach($resourcesDropdownLeft as $section) + @php + $title = $section->title_key ? __($section->title_key) : ($section->title ?? ''); + @endphp + @if(trim((string) $title) !== '') + + @endif + + @foreach($section->items as $item) + @php + $href = $item->resolvedHref(); + $label = $item->resolvedLabel(app()->getLocale()); + @endphp + @if($href && trim((string) $label) !== '') +
    + open_in_new_tab) target="_blank" rel="noopener" @endif + > + {{$label}} + +
    + @endif + @endforeach + @endforeach +
    +
    + @foreach($resourcesDropdownRight as $section) + @php + $title = $section->title_key ? __($section->title_key) : ($section->title ?? ''); + @endphp + @if(trim((string) $title) !== '') + + @endif + + @foreach($section->items as $item) + @php + $href = $item->resolvedHref(); + $label = $item->resolvedLabel(app()->getLocale()); + @endphp + @if($href && trim((string) $label) !== '') +
    + open_in_new_tab) target="_blank" rel="noopener" @endif + > + {{$label}} + +
    + @endif + @endforeach + @endforeach +
    + @else + {{-- Fallback hardcoded menu (keeps working without DB config) --}} +
    + +
    @lang('menu.online-courses')
    +
    @lang('menu.coding@home')
    +
    @lang('menu.training')
    +
    @lang('menu.webinars')
    +
    @lang('menu.podcasts')
    + + +
    @lang('menu.learn')
    +
    @lang('menu.toolkits')
    +
    Share your own lessons
    +
    Share your feedback
    +
    +
    + +
    @lang('menu.careers_in_digital')
    +
    @lang('menu.girls_in_digital')
    +
    Inspiration
    + + +
    @lang('menu.future_ready_csr')
    +
    @lang('menu.matchmaking_toolkit')
    + + +
    @lang('menu.challenges')
    +
    @lang('menu.hackathons')
    +
    @lang('snippets.dance.menu')
    +
    @lang('menu.treasure-hunt')
    +
    Minecraft Education
    +
    + @endif