Skip to content

Commit 425708a

Browse files
committed
training pages dynamic
1 parent ccdbf4f commit 425708a

8 files changed

Lines changed: 550 additions & 27 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\TrainingResource;
6+
use Illuminate\View\View;
7+
8+
class TrainingController extends Controller
9+
{
10+
public function index(): View
11+
{
12+
$dynamicTrainingResources = TrainingResource::active()->ordered()->get();
13+
14+
return view('static.training.index', compact('dynamicTrainingResources'));
15+
}
16+
17+
public function show(string $slug): View
18+
{
19+
$trainingResource = TrainingResource::active()->where('slug', $slug)->firstOrFail();
20+
21+
return view('training.show', compact('trainingResource'));
22+
}
23+
}

app/Nova/TrainingResource.php

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
namespace App\Nova;
4+
5+
use Illuminate\Http\Request;
6+
use Laravel\Nova\Fields\Boolean;
7+
use Laravel\Nova\Fields\ID;
8+
use Laravel\Nova\Fields\Number;
9+
use Laravel\Nova\Fields\Text;
10+
use Laravel\Nova\Fields\Textarea;
11+
use Laravel\Nova\Fields\Trix;
12+
use Laravel\Nova\Http\Requests\NovaRequest;
13+
14+
class TrainingResource extends Resource
15+
{
16+
public static $group = 'Resources';
17+
18+
public static $model = \App\TrainingResource::class;
19+
20+
public static $title = 'card_title';
21+
22+
public static $search = ['slug', 'card_title', 'page_title', 'card_author'];
23+
24+
public static function label()
25+
{
26+
return 'Training Resources';
27+
}
28+
29+
public static function singularLabel()
30+
{
31+
return 'Training Resource';
32+
}
33+
34+
public static function authorizedToViewAny(Request $request): bool
35+
{
36+
return true;
37+
}
38+
39+
public function fields(Request $request): array
40+
{
41+
return [
42+
ID::make()->sortable(),
43+
44+
Text::make('Slug', 'slug')
45+
->rules('nullable', 'max:255', 'alpha_dash', 'unique:training_resources,slug,{{resourceId}}')
46+
->help('Optional. If empty, generated automatically from title. Used in /training/{slug}.'),
47+
48+
Text::make('Card title', 'card_title')
49+
->rules('nullable', 'max:255')
50+
->help('Optional. Shown in the Learning Bits grid on /training'),
51+
52+
Text::make('Card author', 'card_author')
53+
->nullable()
54+
->help('Optional subtitle shown under the card title'),
55+
56+
Text::make('Card image', 'card_image')
57+
->nullable()
58+
->help('Supports full URLs (including Amazon S3/CloudFront) or local paths like /img/learning/my-image.png. Plain filenames are treated as /img/learning/{filename}.'),
59+
60+
Text::make('Page title', 'page_title')->rules('nullable', 'max:255')
61+
->help('Optional. Falls back to card title.'),
62+
63+
Text::make('Hero author', 'hero_author')
64+
->nullable()
65+
->help('Optional pill text in the header banner'),
66+
67+
Trix::make('Intro', 'intro')
68+
->nullable()
69+
->help('Optional intro block shown above the main content'),
70+
71+
Trix::make('Highlight box', 'highlight_box')
72+
->nullable()
73+
->help('Optional styled gray section (e.g. Scientific author / Contributors block).'),
74+
75+
Text::make('Video URL', 'video_url')
76+
->nullable()
77+
->help('Optional YouTube URL. Supports youtu.be, watch, embed, shorts.'),
78+
79+
Text::make('Body image', 'body_image')
80+
->nullable()
81+
->help('Optional image path/URL (supports Amazon S3/CloudFront).'),
82+
83+
Text::make('Body image alt text', 'body_image_alt')
84+
->nullable(),
85+
86+
Trix::make('Content', 'content')
87+
->nullable()
88+
->help('Main training content area'),
89+
90+
Text::make('Button text', 'button_text')->nullable(),
91+
92+
Text::make('Button URL', 'button_url')
93+
->nullable()
94+
->rules('nullable', 'url'),
95+
96+
Text::make('Secondary button text', 'secondary_button_text')->nullable(),
97+
98+
Text::make('Secondary button URL', 'secondary_button_url')
99+
->nullable()
100+
->rules('nullable', 'url'),
101+
102+
Text::make('Meta title', 'meta_title')
103+
->nullable()
104+
->help('Optional HTML title override'),
105+
106+
Textarea::make('Meta description', 'meta_description')
107+
->nullable()
108+
->alwaysShow(),
109+
110+
Number::make('Position', 'position')
111+
->min(0)
112+
->help('Lower = shown first among dynamic resources')
113+
->nullable(),
114+
115+
Boolean::make('Active', 'active'),
116+
];
117+
}
118+
119+
public static function indexQuery(NovaRequest $request, $query)
120+
{
121+
return $query->orderBy('position')->orderBy('created_at', 'desc');
122+
}
123+
}

app/TrainingResource.php

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
namespace App;
4+
5+
use Illuminate\Database\Eloquent\Factories\HasFactory;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Support\Str;
8+
9+
class TrainingResource extends Model
10+
{
11+
use HasFactory;
12+
13+
protected $fillable = [
14+
'slug',
15+
'card_title',
16+
'card_author',
17+
'card_image',
18+
'page_title',
19+
'hero_author',
20+
'intro',
21+
'highlight_box',
22+
'video_url',
23+
'body_image',
24+
'body_image_alt',
25+
'content',
26+
'button_text',
27+
'button_url',
28+
'secondary_button_text',
29+
'secondary_button_url',
30+
'meta_title',
31+
'meta_description',
32+
'position',
33+
'active',
34+
];
35+
36+
protected $casts = [
37+
'active' => 'boolean',
38+
'position' => 'integer',
39+
];
40+
41+
public function scopeActive($query)
42+
{
43+
return $query->where('active', true);
44+
}
45+
46+
public function scopeOrdered($query)
47+
{
48+
return $query->orderBy('position')->orderBy('created_at', 'desc');
49+
}
50+
51+
public function getRouteKeyName(): string
52+
{
53+
return 'slug';
54+
}
55+
56+
protected static function booted(): void
57+
{
58+
static::saving(function (self $resource) {
59+
if (blank($resource->slug)) {
60+
$baseSlug = Str::slug($resource->card_title ?: $resource->page_title ?: 'training-resource');
61+
$resource->slug = $resource->generateUniqueSlug($baseSlug ?: 'training-resource');
62+
}
63+
64+
if (blank($resource->card_title)) {
65+
$resource->card_title = $resource->page_title ?: Str::headline($resource->slug);
66+
}
67+
68+
if (blank($resource->page_title)) {
69+
$resource->page_title = $resource->card_title;
70+
}
71+
});
72+
}
73+
74+
protected function generateUniqueSlug(string $baseSlug): string
75+
{
76+
$slug = $baseSlug;
77+
$counter = 1;
78+
79+
while (self::query()
80+
->where('slug', $slug)
81+
->when($this->exists, fn ($query) => $query->where('id', '!=', $this->id))
82+
->exists()) {
83+
$slug = $baseSlug.'-'.$counter;
84+
$counter++;
85+
}
86+
87+
return $slug;
88+
}
89+
90+
public function getResolvedCardImageAttribute(): string
91+
{
92+
$image = trim((string) $this->card_image);
93+
94+
if ($image === '') {
95+
return '/img/learning/cody-color-kit.png';
96+
}
97+
98+
// Allow absolute URLs (including Amazon S3/CloudFront), protocol-relative, or root-relative paths.
99+
if (Str::startsWith($image, ['http://', 'https://', '//', '/'])) {
100+
return $image;
101+
}
102+
103+
// Backward-compatible shorthand: treat plain filenames as /img/learning/{filename}.
104+
return '/img/learning/'.$image;
105+
}
106+
107+
public function getResolvedBodyImageAttribute(): ?string
108+
{
109+
$image = trim((string) $this->body_image);
110+
111+
if ($image === '') {
112+
return null;
113+
}
114+
115+
if (Str::startsWith($image, ['http://', 'https://', '//', '/'])) {
116+
return $image;
117+
}
118+
119+
return '/img/learning/'.$image;
120+
}
121+
122+
public function getYoutubeVideoIdAttribute(): ?string
123+
{
124+
$url = trim((string) $this->video_url);
125+
if ($url === '') {
126+
return null;
127+
}
128+
129+
$patterns = [
130+
'/youtu\.be\/([a-zA-Z0-9_-]{11})/i',
131+
'/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/i',
132+
'/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/i',
133+
'/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/i',
134+
];
135+
136+
foreach ($patterns as $pattern) {
137+
if (preg_match($pattern, $url, $matches) === 1) {
138+
return $matches[1];
139+
}
140+
}
141+
142+
return null;
143+
}
144+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::create('training_resources', function (Blueprint $table) {
15+
$table->id();
16+
$table->string('slug')->unique();
17+
$table->string('card_title');
18+
$table->string('card_author')->nullable();
19+
$table->string('card_image')->nullable();
20+
$table->string('page_title');
21+
$table->string('hero_author')->nullable();
22+
$table->longText('intro')->nullable();
23+
$table->longText('content')->nullable();
24+
$table->string('button_text')->nullable();
25+
$table->string('button_url')->nullable();
26+
$table->string('meta_title')->nullable();
27+
$table->text('meta_description')->nullable();
28+
$table->unsignedInteger('position')->default(0)->nullable();
29+
$table->boolean('active')->default(true);
30+
$table->timestamps();
31+
});
32+
}
33+
34+
/**
35+
* Reverse the migrations.
36+
*/
37+
public function down(): void
38+
{
39+
Schema::dropIfExists('training_resources');
40+
}
41+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('training_resources', function (Blueprint $table) {
15+
$table->longText('highlight_box')->nullable()->after('intro');
16+
$table->string('video_url')->nullable()->after('highlight_box');
17+
$table->string('body_image')->nullable()->after('video_url');
18+
$table->string('body_image_alt')->nullable()->after('body_image');
19+
$table->string('secondary_button_text')->nullable()->after('button_url');
20+
$table->string('secondary_button_url')->nullable()->after('secondary_button_text');
21+
});
22+
}
23+
24+
/**
25+
* Reverse the migrations.
26+
*/
27+
public function down(): void
28+
{
29+
Schema::table('training_resources', function (Blueprint $table) {
30+
$table->dropColumn([
31+
'highlight_box',
32+
'video_url',
33+
'body_image',
34+
'body_image_alt',
35+
'secondary_button_text',
36+
'secondary_button_url',
37+
]);
38+
});
39+
}
40+
};

0 commit comments

Comments
 (0)