From c2a19a16e62d58b54cb0ad48a74dd7cc31092cc7 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 24 Jul 2026 09:35:18 +0100 Subject: [PATCH] new admin design with mnodules --- .../Http/Controllers/DashboardController.php | 38 +++++++++++++++++++ .../Http/Controllers/ModuleController.php | 26 +++++++++++++ .../Resources/views/dashboard.blade.php | 14 +++++++ .../Resources/views/modules/index.blade.php | 1 + Modules/Administration/Routes/web.php | 7 +++- .../Http/Controllers/AuditController.php | 4 ++ Modules/Audit/Resources/views/index.blade.php | 1 + Modules/Audit/Routes/web.php | 6 +-- .../Http/Controllers/MediaController.php | 8 ++++ Modules/Media/Resources/views/index.blade.php | 1 + Modules/Media/Routes/web.php | 6 +-- .../Roles/Http/Controllers/RoleController.php | 20 ++++++++++ Modules/Roles/Resources/views/form.blade.php | 1 + Modules/Roles/Resources/views/index.blade.php | 1 + Modules/Roles/Routes/web.php | 6 +-- .../Http/Controllers/SearchController.php | 4 ++ .../Search/Resources/views/index.blade.php | 1 + Modules/Search/Routes/web.php | 6 +-- .../Seo/Http/Controllers/SeoController.php | 7 ++++ Modules/Seo/Resources/views/index.blade.php | 1 + Modules/Seo/Routes/web.php | 6 +-- .../Http/Controllers/SettingController.php | 7 ++++ .../Settings/Resources/views/index.blade.php | 1 + Modules/Settings/Routes/web.php | 6 +-- .../Users/Http/Controllers/UserController.php | 31 +++++++++++++++ Modules/Users/Routes/web.php | 6 +-- app/Models/User.php | 3 +- .../components/layouts/app/sidebar.blade.php | 28 ++++++++------ 28 files changed, 205 insertions(+), 42 deletions(-) create mode 100644 Modules/Administration/Http/Controllers/DashboardController.php create mode 100644 Modules/Administration/Http/Controllers/ModuleController.php create mode 100644 Modules/Administration/Resources/views/dashboard.blade.php create mode 100644 Modules/Administration/Resources/views/modules/index.blade.php create mode 100644 Modules/Audit/Http/Controllers/AuditController.php create mode 100644 Modules/Audit/Resources/views/index.blade.php create mode 100644 Modules/Media/Http/Controllers/MediaController.php create mode 100644 Modules/Media/Resources/views/index.blade.php create mode 100644 Modules/Roles/Http/Controllers/RoleController.php create mode 100644 Modules/Roles/Resources/views/form.blade.php create mode 100644 Modules/Roles/Resources/views/index.blade.php create mode 100644 Modules/Search/Http/Controllers/SearchController.php create mode 100644 Modules/Search/Resources/views/index.blade.php create mode 100644 Modules/Seo/Http/Controllers/SeoController.php create mode 100644 Modules/Seo/Resources/views/index.blade.php create mode 100644 Modules/Settings/Http/Controllers/SettingController.php create mode 100644 Modules/Settings/Resources/views/index.blade.php create mode 100644 Modules/Users/Http/Controllers/UserController.php diff --git a/Modules/Administration/Http/Controllers/DashboardController.php b/Modules/Administration/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..118ebf7 --- /dev/null +++ b/Modules/Administration/Http/Controllers/DashboardController.php @@ -0,0 +1,38 @@ + User::count(), + 'roleCount' => Schema::hasTable('roles') ? Role::count() : 0, + 'mediaCount' => Schema::hasTable('media') ? Media::count() : 0, + 'auditCount' => Schema::hasTable('audit_logs') ? AuditLog::count() : 0, + 'recentAudit' => Schema::hasTable('audit_logs') ? AuditLog::query()->latest('created_at')->limit(8)->get() : collect(), + 'modules' => $this->modules(), + ]); + } + + private function modules(): array + { + $statuses = json_decode((string) file_get_contents(base_path('modules_statuses.json')), true) ?: []; + $modules = []; + foreach (glob(base_path('Modules/*/module.json')) ?: [] as $manifest) { + $data = json_decode((string) file_get_contents($manifest), true) ?: []; + $name = $data['name'] ?? basename(dirname($manifest)); + $modules[] = ['name' => $name, 'version' => $data['version'] ?? '1.0.0', 'enabled' => (bool) ($statuses[$name] ?? false)]; + } + usort($modules, fn ($a, $b) => strcasecmp($a['name'], $b['name'])); + return $modules; + } +} diff --git a/Modules/Administration/Http/Controllers/ModuleController.php b/Modules/Administration/Http/Controllers/ModuleController.php new file mode 100644 index 0000000..3ad262e --- /dev/null +++ b/Modules/Administration/Http/Controllers/ModuleController.php @@ -0,0 +1,26 @@ +map(function (string $manifest) use ($statuses): array { + $data = json_decode((string) file_get_contents($manifest), true) ?: []; + $name = $data['name'] ?? basename(dirname($manifest)); + return [ + 'name' => $name, + 'description' => $data['description'] ?? 'Laravel application module.', + 'version' => $data['version'] ?? '1.0.0', + 'enabled' => (bool) ($statuses[$name] ?? false), + 'providers' => $data['providers'] ?? [], + ]; + })->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)->values(); + + return view('administration::modules.index', compact('modules')); + } +} diff --git a/Modules/Administration/Resources/views/dashboard.blade.php b/Modules/Administration/Resources/views/dashboard.blade.php new file mode 100644 index 0000000..36836ef --- /dev/null +++ b/Modules/Administration/Resources/views/dashboard.blade.php @@ -0,0 +1,14 @@ + +
+

Administration

Platform health and recent activity.

+
+ @foreach([['Users',$userCount,'users'],['Roles',$roleCount,'shield-check'],['Media',$mediaCount,'photo'],['Audit events',$auditCount,'clipboard-document-list']] as [$label,$count,$icon]) +
{{ $count }}
{{ $label }}
+ @endforeach +
+
+
Modules
@foreach($modules as $module)
{{ $module['name'] }}
v{{ $module['version'] }}
{{ $module['enabled'] ? 'Enabled' : 'Disabled' }}
@endforeach
+
Recent audit activity
@forelse($recentAudit as $log)
{{ $log->event }}
{{ optional($log->created_at)->diffForHumans() }}
@empty
No audit events yet.
@endforelse
+
+
+
diff --git a/Modules/Administration/Resources/views/modules/index.blade.php b/Modules/Administration/Resources/views/modules/index.blade.php new file mode 100644 index 0000000..69bbd42 --- /dev/null +++ b/Modules/Administration/Resources/views/modules/index.blade.php @@ -0,0 +1 @@ +

Modules

Installed platform modules and runtime status.

@foreach($modules as $module)@endforeach
ModuleVersionStatusProviders
{{ $module['name'] }}
{{ $module['description'] }}
{{ $module['version'] }}{{ $module['enabled'] ? 'Enabled' : 'Disabled' }}{{ implode(', ', $module['providers']) ?: '—' }}
diff --git a/Modules/Administration/Routes/web.php b/Modules/Administration/Routes/web.php index d6152a0..1aa560c 100644 --- a/Modules/Administration/Routes/web.php +++ b/Modules/Administration/Routes/web.php @@ -1,5 +1,10 @@ prefix('administration')->name('administration.')->group(function (): void { + Route::get('/', DashboardController::class)->name('dashboard'); + Route::get('/modules', [ModuleController::class, 'index'])->name('modules.index'); +}); diff --git a/Modules/Audit/Http/Controllers/AuditController.php b/Modules/Audit/Http/Controllers/AuditController.php new file mode 100644 index 0000000..0a3944d --- /dev/null +++ b/Modules/Audit/Http/Controllers/AuditController.php @@ -0,0 +1,4 @@ +AuditLog::query()->latest('created_at')->paginate(50)]); } } diff --git a/Modules/Audit/Resources/views/index.blade.php b/Modules/Audit/Resources/views/index.blade.php new file mode 100644 index 0000000..7bbc774 --- /dev/null +++ b/Modules/Audit/Resources/views/index.blade.php @@ -0,0 +1 @@ +

Audit log

Recorded platform activity.

@forelse($logs as $log)@empty@endforelse
EventActorIPDate
{{ $log->event }}{{ $log->actor_type ? class_basename($log->actor_type).' #'.$log->actor_id : 'System' }}{{ $log->ip_address ?: '—' }}{{ optional($log->created_at)->format('d M Y H:i') }}
No audit events recorded.
{{ $logs->links() }}
diff --git a/Modules/Audit/Routes/web.php b/Modules/Audit/Routes/web.php index d6152a0..f4a649b 100644 --- a/Modules/Audit/Routes/web.php +++ b/Modules/Audit/Routes/web.php @@ -1,5 +1,3 @@ get('administration/audit',[AuditController::class,'index'])->name('administration.audit.index'); diff --git a/Modules/Media/Http/Controllers/MediaController.php b/Modules/Media/Http/Controllers/MediaController.php new file mode 100644 index 0000000..f9dcb90 --- /dev/null +++ b/Modules/Media/Http/Controllers/MediaController.php @@ -0,0 +1,8 @@ +Media::query()->latest()->paginate(30)]); } + public function store(Request $request){ $request->validate(['file'=>['required','file','max:20480'],'title'=>['nullable','string','max:255'],'alt_text'=>['nullable','string','max:255']]); $file=$request->file('file'); $path=$file->store('media','public'); Media::create(['disk'=>'public','path'=>$path,'original_name'=>$file->getClientOriginalName(),'mime_type'=>$file->getMimeType(),'size'=>$file->getSize(),'title'=>$request->string('title')->toString() ?: pathinfo($file->getClientOriginalName(),PATHINFO_FILENAME),'alt_text'=>$request->string('alt_text')->toString() ?: null,'uploaded_by'=>$request->user()->id]); return back()->with('status','Media uploaded.'); } + public function destroy(Media $medium){ Storage::disk($medium->disk)->delete($medium->path); $medium->delete(); return back()->with('status','Media deleted.'); } +} diff --git a/Modules/Media/Resources/views/index.blade.php b/Modules/Media/Resources/views/index.blade.php new file mode 100644 index 0000000..e574feb --- /dev/null +++ b/Modules/Media/Resources/views/index.blade.php @@ -0,0 +1 @@ +

Media library

Upload and manage reusable files.

@if(session('status'))
{{ session('status') }}
@endif
@csrf
Upload
@forelse($media as $item)
@if(str_starts_with((string)$item->mime_type,'image/')){{ $item->alt_text }}@else
@endif
{{ $item->title ?: $item->original_name }}
{{ Number::fileSize($item->size) }}
@csrf @method('DELETE')Delete
@empty
No media uploaded.
@endforelse
{{ $media->links() }}
diff --git a/Modules/Media/Routes/web.php b/Modules/Media/Routes/web.php index d6152a0..4d9162f 100644 --- a/Modules/Media/Routes/web.php +++ b/Modules/Media/Routes/web.php @@ -1,5 +1,3 @@ prefix('administration/media')->name('administration.media.')->group(function (): void { Route::get('/',[MediaController::class,'index'])->name('index'); Route::post('/',[MediaController::class,'store'])->name('store'); Route::delete('/{medium}',[MediaController::class,'destroy'])->name('destroy'); }); diff --git a/Modules/Roles/Http/Controllers/RoleController.php b/Modules/Roles/Http/Controllers/RoleController.php new file mode 100644 index 0000000..f56c8d0 --- /dev/null +++ b/Modules/Roles/Http/Controllers/RoleController.php @@ -0,0 +1,20 @@ +Role::withCount(['users','permissions'])->orderBy('name')->get()]); } + public function create() { return view('roles::form',['role'=>new Role(['guard_name'=>'web']),'permissions'=>Permission::orderBy('name')->get()]); } + public function store(Request $request) { $data=$this->validated($request); $role=Role::create(['name'=>$data['name'],'guard_name'=>'web']); $role->syncPermissions($data['permissions']??[]); return redirect()->route('administration.roles.index')->with('status','Role created.'); } + public function edit(Role $role) { return view('roles::form',['role'=>$role->load('permissions'),'permissions'=>Permission::orderBy('name')->get()]); } + public function update(Request $request, Role $role) { $data=$this->validated($request,$role); $role->update(['name'=>$data['name']]); $role->syncPermissions($data['permissions']??[]); return redirect()->route('administration.roles.index')->with('status','Role updated.'); } + public function destroy(Role $role) { abort_if($role->name==='admin',422,'The administrator role cannot be deleted.'); $role->delete(); return back()->with('status','Role deleted.'); } + private function validated(Request $request, ?Role $role=null): array { return $request->validate(['name'=>['required','string','max:100',Rule::unique('roles','name')->ignore($role)],'permissions'=>['array'],'permissions.*'=>['string','exists:permissions,name']]); } +} diff --git a/Modules/Roles/Resources/views/form.blade.php b/Modules/Roles/Resources/views/form.blade.php new file mode 100644 index 0000000..202fec9 --- /dev/null +++ b/Modules/Roles/Resources/views/form.blade.php @@ -0,0 +1 @@ +

{{ $role->exists ? 'Edit role' : 'Create role' }}

@csrf @if($role->exists)@method('PUT')@endif
Permissions
@foreach($permissions as $permission)@endforeach
CancelSave
diff --git a/Modules/Roles/Resources/views/index.blade.php b/Modules/Roles/Resources/views/index.blade.php new file mode 100644 index 0000000..6ccb021 --- /dev/null +++ b/Modules/Roles/Resources/views/index.blade.php @@ -0,0 +1 @@ +

Roles & permissions

Control administrative access.

Create role
@if(session('status'))
{{ session('status') }}
@endif
@foreach($roles as $role)
{{ $role->name }}
{{ $role->users_count }} users · {{ $role->permissions_count }} permissions
Edit@if($role->name!=='admin')
@csrf @method('DELETE')Delete
@endif
@endforeach
diff --git a/Modules/Roles/Routes/web.php b/Modules/Roles/Routes/web.php index d6152a0..fd675d0 100644 --- a/Modules/Roles/Routes/web.php +++ b/Modules/Roles/Routes/web.php @@ -1,5 +1,3 @@ prefix('administration/roles')->name('administration.roles.')->group(function (): void { Route::resource('', RoleController::class)->parameters([''=>'role'])->except(['show']); }); diff --git a/Modules/Search/Http/Controllers/SearchController.php b/Modules/Search/Http/Controllers/SearchController.php new file mode 100644 index 0000000..b74a619 --- /dev/null +++ b/Modules/Search/Http/Controllers/SearchController.php @@ -0,0 +1,4 @@ +query('q')); $results=DB::table('search_index')->when($q!== '',fn($query)=>$query->where(fn($w)=>$w->where('title','like',"%{$q}%")->orWhere('summary','like',"%{$q}%")->orWhere('content','like',"%{$q}%")))->latest('updated_at')->paginate(25)->withQueryString(); return view('search::index',compact('results','q')); } } diff --git a/Modules/Search/Resources/views/index.blade.php b/Modules/Search/Resources/views/index.blade.php new file mode 100644 index 0000000..07ea084 --- /dev/null +++ b/Modules/Search/Resources/views/index.blade.php @@ -0,0 +1 @@ +

Search index

Inspect content currently available to platform search.

Search
@forelse($results as $result)
{{ $result->title }}
{{ str($result->summary ?: $result->content)->limit(180) }}
{{ $result->searchable_type }} #{{ $result->searchable_id }}
@empty
No indexed content found.
@endforelse
{{ $results->links() }}
diff --git a/Modules/Search/Routes/web.php b/Modules/Search/Routes/web.php index d6152a0..1a4abbc 100644 --- a/Modules/Search/Routes/web.php +++ b/Modules/Search/Routes/web.php @@ -1,5 +1,3 @@ get('administration/search',[SearchController::class,'index'])->name('administration.search.index'); diff --git a/Modules/Seo/Http/Controllers/SeoController.php b/Modules/Seo/Http/Controllers/SeoController.php new file mode 100644 index 0000000..24090e9 --- /dev/null +++ b/Modules/Seo/Http/Controllers/SeoController.php @@ -0,0 +1,7 @@ +pluck('value','key'); return view('seo::index',compact('values')); } + public function update(Request $request){ $data=$request->validate(['site_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string','max:500'],'robots'=>['required','string','max:100'],'canonical_base'=>['nullable','url','max:255']]); foreach($data as $key=>$value) Setting::updateOrCreate(['group'=>'seo','key'=>$key],['value'=>$value,'type'=>'string','is_public'=>true]); return back()->with('status','SEO defaults saved.'); } +} diff --git a/Modules/Seo/Resources/views/index.blade.php b/Modules/Seo/Resources/views/index.blade.php new file mode 100644 index 0000000..9939a86 --- /dev/null +++ b/Modules/Seo/Resources/views/index.blade.php @@ -0,0 +1 @@ +

SEO defaults

Site-wide metadata used when content does not provide its own values.

@if(session('status'))
{{ session('status') }}
@endif
@csrf @method('PUT'){{ old('meta_description',$values['meta_description']??'') }}
Save SEO defaults
diff --git a/Modules/Seo/Routes/web.php b/Modules/Seo/Routes/web.php index d6152a0..6d8e273 100644 --- a/Modules/Seo/Routes/web.php +++ b/Modules/Seo/Routes/web.php @@ -1,5 +1,3 @@ prefix('administration/seo')->name('administration.seo.')->group(function (): void { Route::get('/',[SeoController::class,'index'])->name('index'); Route::put('/',[SeoController::class,'update'])->name('update'); }); diff --git a/Modules/Settings/Http/Controllers/SettingController.php b/Modules/Settings/Http/Controllers/SettingController.php new file mode 100644 index 0000000..e4aacfd --- /dev/null +++ b/Modules/Settings/Http/Controllers/SettingController.php @@ -0,0 +1,7 @@ +orderBy('group')->orderBy('key')->get(); return view('settings::index',compact('settings')); } + public function update(Request $request){ $data=$request->validate(['settings'=>['array'],'settings.*'=>['nullable','string','max:5000']]); foreach($data['settings']??[] as $id=>$value){ Setting::whereKey($id)->update(['value'=>$value]); } return back()->with('status','Settings saved.'); } +} diff --git a/Modules/Settings/Resources/views/index.blade.php b/Modules/Settings/Resources/views/index.blade.php new file mode 100644 index 0000000..fd17a01 --- /dev/null +++ b/Modules/Settings/Resources/views/index.blade.php @@ -0,0 +1 @@ +

Platform settings

Shared configuration stored by the Settings module.

@if(session('status'))
{{ session('status') }}
@endif
@csrf @method('PUT')@forelse($settings->groupBy('group') as $group=>$items)

{{ $group }}

@foreach($items as $setting)@endforeach
@empty
No settings have been registered yet. SEO defaults can be configured from the SEO screen.
@endforelse
Save settings
diff --git a/Modules/Settings/Routes/web.php b/Modules/Settings/Routes/web.php index d6152a0..fe860fd 100644 --- a/Modules/Settings/Routes/web.php +++ b/Modules/Settings/Routes/web.php @@ -1,5 +1,3 @@ prefix('administration/settings')->name('administration.settings.')->group(function (): void { Route::get('/',[SettingController::class,'index'])->name('index'); Route::put('/',[SettingController::class,'update'])->name('update'); }); diff --git a/Modules/Users/Http/Controllers/UserController.php b/Modules/Users/Http/Controllers/UserController.php new file mode 100644 index 0000000..6bf2b99 --- /dev/null +++ b/Modules/Users/Http/Controllers/UserController.php @@ -0,0 +1,31 @@ + User::with('roles')->latest()->paginate(20)]); } + public function create() { return view('stylist-theme-admin::administration.users.create', ['roles' => Role::orderBy('name')->pluck('name')]); } + public function store(Request $request) { + $data=$request->validate(['name'=>['required','string','max:255'],'email'=>['required','email','max:255','unique:users,email'],'password'=>['required','string','min:8','confirmed'],'roles'=>['array'],'roles.*'=>['string','exists:roles,name']]); + $user=User::create($data); $user->syncRoles($data['roles'] ?? []); + return redirect()->route('administration.users.index')->with('status','User created.'); + } + public function edit(User $user) { return view('stylist-theme-admin::administration.users.edit', ['user'=>$user->load('roles'),'roles'=>Role::orderBy('name')->pluck('name')]); } + public function update(Request $request, User $user) { + $data=$request->validate(['name'=>['required','string','max:255'],'email'=>['required','email','max:255',Rule::unique('users','email')->ignore($user)],'password'=>['nullable','string','min:8','confirmed'],'roles'=>['array'],'roles.*'=>['string','exists:roles,name']]); + if (blank($data['password'] ?? null)) unset($data['password']); + $user->update($data); $user->syncRoles($data['roles'] ?? []); + return redirect()->route('administration.users.index')->with('status','User updated.'); + } + public function destroy(User $user) { + abort_if($user->is(auth()->user()),422,'You cannot delete your own account.'); + $user->delete(); return redirect()->route('administration.users.index')->with('status','User deleted.'); + } +} diff --git a/Modules/Users/Routes/web.php b/Modules/Users/Routes/web.php index d6152a0..d8939b0 100644 --- a/Modules/Users/Routes/web.php +++ b/Modules/Users/Routes/web.php @@ -1,5 +1,3 @@ prefix('administration/users')->name('administration.users.')->group(function (): void { Route::resource('', UserController::class)->parameters([''=>'user'])->except(['show']); }); diff --git a/app/Models/User.php b/app/Models/User.php index 047225e..bb55fd9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ use Illuminate\Support\Str; use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; +use Spatie\Permission\Traits\HasRoles; /** * @property int $id @@ -33,7 +34,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable implements PasskeyUser { /** @use HasFactory */ - use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; + use HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; /** * Get the attributes that should be cast. diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index 7fda82c..8f3b850 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -12,23 +12,27 @@ - - {{ __('Dashboard') }} - + {{ __('Dashboard') }} + {{ __('Administration') }} + + + + {{ __('Users') }} + {{ __('Roles & Permissions') }} + {{ __('Media') }} + {{ __('Search') }} + + + + {{ __('SEO') }} + {{ __('Settings') }} + {{ __('Modules') }} + {{ __('Audit Log') }} - - - {{ __('Repository') }} - - - - {{ __('Documentation') }} - -