Skip to content

Commit fef2293

Browse files
authored
Merge pull request #3529 from codeeu/feature/ddp-training-resource
Feature/ddp training resource
2 parents 6a0e2c3 + 1b528e1 commit fef2293

10 files changed

Lines changed: 249 additions & 14 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use Illuminate\Support\Facades\Cache;
6+
use Illuminate\Support\Facades\Http;
7+
8+
class TrainingRoadmapPdfController extends Controller
9+
{
10+
/**
11+
* Public roadmap PDF used by the Discover Digital Programme embed.
12+
* Kept allowlisted here so the proxy cannot be abused as an open redirect.
13+
*/
14+
private const ROADMAP_SOURCE = 'https://codeweek-resources.s3.eu-west-1.amazonaws.com/+discover-digital-toolkit/DDP_toolkit_roadmap.pdf';
15+
16+
/**
17+
* Same-origin PDF bytes for PDF.js (avoids cross-origin fetch issues).
18+
*/
19+
public function proxyPdf()
20+
{
21+
$bytes = Cache::remember(
22+
'training_embedded_roadmap_pdf_v1',
23+
3600,
24+
function () {
25+
$response = Http::timeout(120)->get(self::ROADMAP_SOURCE);
26+
27+
if (! $response->successful()) {
28+
abort(502, 'Unable to load roadmap PDF.');
29+
}
30+
31+
return $response->body();
32+
}
33+
);
34+
35+
return response($bytes, 200, [
36+
'Content-Type' => 'application/pdf',
37+
'Content-Disposition' => 'inline; filename="DDP_toolkit_roadmap.pdf"',
38+
'Cache-Control' => 'public, max-age=3600',
39+
]);
40+
}
41+
42+
/**
43+
* Minimal PDF.js viewer (no site chrome) for iframe embedding on training pages.
44+
*/
45+
public function viewer()
46+
{
47+
return response()
48+
->view('training.roadmap-pdfjs')
49+
->header('X-Frame-Options', 'SAMEORIGIN');
50+
}
51+
}

app/Nova/TrainingResource.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
use Illuminate\Http\Request;
77
use Illuminate\Support\Facades\URL;
88
use Laravel\Nova\Fields\Boolean;
9+
use Laravel\Nova\Fields\FormData;
910
use Laravel\Nova\Fields\ID;
1011
use Laravel\Nova\Fields\Number;
12+
use Laravel\Nova\Fields\Select;
1113
use Laravel\Nova\Fields\Text;
1214
use Laravel\Nova\Fields\Textarea;
1315
use Laravel\Nova\Fields\Trix;
@@ -153,10 +155,38 @@ public function fields(Request $request): array
153155
->nullable()
154156
->help('Optional scroll offset in pixels for in-page anchor links (useful with sticky headers).'),
155157

158+
Select::make('Roadmap embed format', 'roadmap_embed_kind')
159+
->options([
160+
'pdf' => 'PDF (inline viewer)',
161+
'svg' => 'SVG (paste markup below)',
162+
'none' => 'None (remove placeholder output)',
163+
])
164+
->default('pdf')
165+
->help('Use [[embed_roadmap_pdf]] or [[embed_roadmap]] in Content where the roadmap should appear.'),
166+
156167
Text::make('Roadmap PDF embed URL', 'roadmap_pdf_embed_url')
157168
->nullable()
158169
->rules('nullable', 'url')
159-
->help('Optional HTTPS URL to a PDF shown inline in the Roadmap section. Put the literal text [[embed_roadmap_pdf]] in Content where the embed should appear (avoids Nova stripping iframes).'),
170+
->dependsOn(['roadmap_embed_kind'], function (Text $field, NovaRequest $request, FormData $formData) {
171+
if (($formData->roadmap_embed_kind ?? 'pdf') === 'pdf') {
172+
$field->show();
173+
} else {
174+
$field->hide();
175+
}
176+
})
177+
->help('HTTPS URL to the roadmap PDF. Used when format is PDF.'),
178+
179+
Textarea::make('Roadmap SVG', 'roadmap_svg')
180+
->nullable()
181+
->rows(10)
182+
->dependsOn(['roadmap_embed_kind'], function (Textarea $field, NovaRequest $request, FormData $formData) {
183+
if (($formData->roadmap_embed_kind ?? 'pdf') === 'svg') {
184+
$field->show();
185+
} else {
186+
$field->hide();
187+
}
188+
})
189+
->help('Full <svg>...</svg> markup from your design tool. Used when format is SVG.'),
160190

161191
Text::make('Button text', 'button_text')->nullable(),
162192

app/TrainingResource.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class TrainingResource extends Model
3535
'about_box_section',
3636
'anchor_offset',
3737
'roadmap_pdf_embed_url',
38+
'roadmap_embed_kind',
39+
'roadmap_svg',
3840
'button_text',
3941
'button_url',
4042
'secondary_button_text',
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
public function up(): void
10+
{
11+
Schema::table('training_resources', function (Blueprint $table) {
12+
$table->string('roadmap_embed_kind', 16)->default('pdf')->after('roadmap_pdf_embed_url');
13+
$table->mediumText('roadmap_svg')->nullable()->after('roadmap_embed_kind');
14+
});
15+
}
16+
17+
public function down(): void
18+
{
19+
Schema::table('training_resources', function (Blueprint $table) {
20+
$table->dropColumn(['roadmap_embed_kind', 'roadmap_svg']);
21+
});
22+
}
23+
};

database/seeders/TrainingResourceDiscoverDigitalProgrammeSeeder.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ public function run(): void
9191
HTML,
9292
'anchor_offset' => 120,
9393
'roadmap_pdf_embed_url' => 'https://codeweek-resources.s3.eu-west-1.amazonaws.com/+discover-digital-toolkit/DDP_toolkit_roadmap.pdf',
94+
'roadmap_embed_kind' => 'pdf',
95+
'roadmap_svg' => null,
9496
'third_button_text' => 'Register an activity',
9597
'third_button_url' => 'https://codeweek.eu/add?skip=1',
9698
'meta_title' => 'Discover Digital Programme - Toolkit',

resources/views/training/partials/roadmap-pdf-embed.blade.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
@props([
22
'url',
33
])
4+
@php
5+
// Strip any fragment from stored URL for the "open in new tab" link.
6+
$tabUrl = \Illuminate\Support\Str::before($url, '#');
7+
// Same-origin PDF.js viewer for the allowlisted DDP roadmap (see TrainingRoadmapPdfController).
8+
// Other URLs keep the direct PDF iframe with viewer chrome hints.
9+
$ddpRoadmapBase = 'https://codeweek-resources.s3.eu-west-1.amazonaws.com/+discover-digital-toolkit/DDP_toolkit_roadmap.pdf';
10+
$usePdfjsViewer = strtolower(rtrim($tabUrl, '/')) === strtolower(rtrim($ddpRoadmapBase, '/'));
11+
$fragment = '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
12+
$embedSrc = $usePdfjsViewer ? route('training.roadmap_pdf_viewer') : $tabUrl.$fragment;
13+
@endphp
414
<div class="w-full max-w-full my-6 rounded-xl overflow-hidden border border-slate-200 bg-slate-100 shadow-sm">
515
<iframe
616
title="{{ __('Roadmap (PDF)') }}"
7-
src="{{ $url }}"
8-
class="w-full border-0 block"
9-
style="height: min(75vh, 880px); min-height: 480px;"
17+
src="{{ $embedSrc }}"
18+
class="w-full border-0 block max-w-full"
19+
style="width: 100%; height: min(85dvh, 920px); min-height: min(60dvh, 520px);"
1020
loading="lazy"
21+
referrerpolicy="no-referrer-when-downgrade"
1122
></iframe>
1223
</div>
1324
<p class="text-sm mt-2 mb-0 text-[#333E48]">
14-
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="text-dark-blue underline font-medium">
25+
<a href="{{ $tabUrl }}" target="_blank" rel="noopener noreferrer" class="text-dark-blue underline font-medium">
1526
{{ __('Open roadmap PDF in a new tab') }}
1627
</a>
1728
{{ __('if the preview does not load in your browser.') }}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@props([
2+
'svg',
3+
])
4+
<div class="w-full max-w-full my-6 rounded-xl overflow-hidden border border-slate-200 bg-slate-50 shadow-sm [&_svg]:max-w-full [&_svg]:h-auto [&_svg]:block">
5+
{!! $svg !!}
6+
</div>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<!DOCTYPE html>
2+
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<meta name="robots" content="noindex, nofollow">
7+
<title>{{ __('Roadmap') }}</title>
8+
<style>
9+
html, body { margin: 0; padding: 0; background: #f1f5f9; min-height: 100%; }
10+
#pdf-scroll { max-width: 100%; padding: 8px; box-sizing: border-box; overflow-x: hidden; overflow-y: auto; }
11+
#pdf-error { color: #b91c1c; padding: 1rem; font-family: system-ui, sans-serif; font-size: 0.9rem; }
12+
canvas { display: block; max-width: 100%; height: auto !important; margin: 0 auto 1rem; border-radius: 6px; box-shadow: 0 1px 3px rgb(0 0 0 / 0.12); background: #fff; }
13+
</style>
14+
</head>
15+
<body>
16+
<div id="pdf-error" role="alert"></div>
17+
<div id="pdf-scroll"></div>
18+
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
19+
<script>
20+
(async function () {
21+
var pdfUrl = @json(url(route('training.embedded_pdf.roadmap')));
22+
var pdfjsLib = window.pdfjsLib || window['pdfjs-dist/build/pdf'];
23+
var container = document.getElementById('pdf-scroll');
24+
var err = document.getElementById('pdf-error');
25+
26+
if (!pdfjsLib || typeof pdfjsLib.getDocument !== 'function') {
27+
err.textContent = 'PDF viewer failed to load.';
28+
return;
29+
}
30+
31+
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
32+
33+
try {
34+
var pdf = await pdfjsLib.getDocument({ url: pdfUrl }).promise;
35+
36+
async function renderAll() {
37+
container.innerHTML = '';
38+
var cw = Math.max(200, container.clientWidth || window.innerWidth);
39+
for (var p = 1; p <= pdf.numPages; p++) {
40+
var page = await pdf.getPage(p);
41+
var base = page.getViewport({ scale: 1 });
42+
var scale = Math.min(2.2, Math.max(0.4, (cw - 16) / base.width));
43+
var viewport = page.getViewport({ scale: scale });
44+
var canvas = document.createElement('canvas');
45+
var ctx = canvas.getContext('2d', { alpha: false });
46+
canvas.width = viewport.width;
47+
canvas.height = viewport.height;
48+
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
49+
container.appendChild(canvas);
50+
}
51+
}
52+
53+
await renderAll();
54+
var t;
55+
window.addEventListener('resize', function () {
56+
clearTimeout(t);
57+
t = setTimeout(function () { renderAll(); }, 200);
58+
});
59+
} catch (e) {
60+
err.textContent = (e && e.message) ? e.message : 'Could not open PDF.';
61+
}
62+
})();
63+
</script>
64+
</body>
65+
</html>

resources/views/training/show.blade.php

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
$pageDescription = $trainingResource->meta_description ?: $fallbackDescription;
1212
1313
$introClass = "text-[#20262C] font-normal text-lg md:text-xl p-0 mb-6 [&_p]:p-0 [&_p]:mb-6 [&_a]:text-dark-blue [&_a]:hover:underline";
14-
$contentClass = "text-[#333E48] font-normal text-lg md:text-xl p-0 mb-6 [&_p]:p-0 [&_p]:mb-6 [&_h2]:text-dark-blue [&_h2]:text-2xl [&_h2]:md:text-3xl [&_h2]:leading-[44px] [&_h2]:font-medium [&_h2]:font-['Montserrat'] [&_h2]:mb-4 [&_h3]:text-dark-blue [&_h3]:text-xl [&_h3]:md:text-2xl [&_h3]:font-medium [&_h3]:font-['Montserrat'] [&_h3]:mb-4 [&_ul]:pl-8 [&_ul]:m-0 [&_ul]:mb-6 [&_ul]:list-disc [&_ol]:pl-8 [&_ol]:m-0 [&_ol]:mb-6 [&_ol]:list-decimal [&_li]:p-0 [&_li]:text-lg [&_li]:font-normal [&_li]:leading-7 [&_li]:text-default [&_a]:text-dark-blue [&_a]:hover:underline [&_img]:max-w-full [&_img]:w-auto [&_img]:h-auto [&_img]:my-8 [&_img]:mx-auto [&_img]:block";
14+
$contentClass = "text-[#333E48] font-normal text-lg md:text-xl p-0 mb-6 min-w-0 [&_p]:p-0 [&_p]:mb-6 [&_h2]:text-dark-blue [&_h2]:text-2xl [&_h2]:md:text-3xl [&_h2]:leading-[44px] [&_h2]:font-medium [&_h2]:font-['Montserrat'] [&_h2]:mb-4 [&_h3]:text-dark-blue [&_h3]:text-xl [&_h3]:md:text-2xl [&_h3]:font-medium [&_h3]:font-['Montserrat'] [&_h3]:mb-4 [&_ul]:pl-8 [&_ul]:m-0 [&_ul]:mb-6 [&_ul]:list-disc [&_ol]:pl-8 [&_ol]:m-0 [&_ol]:mb-6 [&_ol]:list-decimal [&_li]:p-0 [&_li]:text-lg [&_li]:font-normal [&_li]:leading-7 [&_li]:text-default [&_a]:text-dark-blue [&_a]:hover:underline [&_a]:break-words [&_img]:max-w-full [&_img]:w-auto [&_img]:h-auto [&_img]:my-8 [&_img]:mx-auto [&_img]:block [&_svg]:max-w-full [&_svg]:h-auto [&_svg]:block";
1515
$pdfClass = "text-[#333E48] font-normal text-lg md:text-xl p-0 mb-6 [&_p]:p-0 [&_p]:mb-4 [&_h2]:text-dark-blue [&_h2]:text-2xl [&_h2]:md:text-3xl [&_h2]:leading-[44px] [&_h2]:font-medium [&_h2]:font-['Montserrat'] [&_h2]:mb-4 [&_ul]:m-0 [&_ul]:mb-6 [&_ul]:list-none [&_ol]:m-0 [&_ol]:mb-6 [&_ol]:list-none [&_li]:p-0 [&_li]:mb-2 [&_li]:font-normal [&_li]:leading-7 [&_li]:text-default [&_li]:break-words [&_a]:text-lg [&_a]:text-dark-blue [&_a]:no-underline [&_a:hover]:underline [&_a]:break-words [&_a]:max-w-full";
1616
$contactsClass = "text-[#333E48] font-normal text-lg md:text-xl p-0 mb-8 [&_p]:p-0 [&_p]:mb-4 [&_h2]:text-dark-blue [&_h2]:text-2xl [&_h2]:md:text-3xl [&_h2]:leading-[44px] [&_h2]:font-medium [&_h2]:font-['Montserrat'] [&_h2]:mb-4 [&_a]:text-dark-blue [&_a]:hover:underline";
1717
$registerClass = "text-[#333E48] font-normal text-base md:text-lg [&_p]:p-0 [&_p]:mb-4 [&_p:last-child]:mb-0 [&_a]:font-medium [&_a]:text-dark-blue [&_a]:hover:underline";
@@ -26,14 +26,29 @@
2626
2727
$renderedContent = $trainingResource->content ?? '';
2828
$roadmapEmbedUrl = trim((string) ($trainingResource->roadmap_pdf_embed_url ?? ''));
29-
if ($roadmapEmbedUrl !== '' && str_contains($renderedContent, '[[embed_roadmap_pdf]]')) {
30-
$renderedContent = str_replace(
31-
'[[embed_roadmap_pdf]]',
32-
view('training.partials.roadmap-pdf-embed', ['url' => $roadmapEmbedUrl])->render(),
33-
$renderedContent
34-
);
35-
} elseif (str_contains($renderedContent, '[[embed_roadmap_pdf]]')) {
36-
$renderedContent = str_replace('[[embed_roadmap_pdf]]', '', $renderedContent);
29+
$roadmapEmbedKind = strtolower(trim((string) ($trainingResource->roadmap_embed_kind ?? 'pdf')));
30+
if (! in_array($roadmapEmbedKind, ['pdf', 'svg', 'none'], true)) {
31+
$roadmapEmbedKind = 'pdf';
32+
}
33+
$roadmapSvg = trim((string) ($trainingResource->roadmap_svg ?? ''));
34+
35+
$roadmapPlaceholders = ['[[embed_roadmap_pdf]]', '[[embed_roadmap]]'];
36+
$hasRoadmapPlaceholder = str_contains($renderedContent, '[[embed_roadmap_pdf]]')
37+
|| str_contains($renderedContent, '[[embed_roadmap]]');
38+
39+
if ($hasRoadmapPlaceholder) {
40+
$roadmapEmbedHtml = '';
41+
if ($roadmapEmbedKind === 'pdf' && $roadmapEmbedUrl !== '') {
42+
$roadmapEmbedHtml = view('training.partials.roadmap-pdf-embed', ['url' => $roadmapEmbedUrl])->render();
43+
} elseif ($roadmapEmbedKind === 'svg' && $roadmapSvg !== '') {
44+
$roadmapEmbedHtml = view('training.partials.roadmap-svg-embed', ['svg' => $roadmapSvg])->render();
45+
}
46+
47+
foreach ($roadmapPlaceholders as $token) {
48+
if (str_contains($renderedContent, $token)) {
49+
$renderedContent = str_replace($token, $roadmapEmbedHtml, $renderedContent);
50+
}
51+
}
3752
}
3853
@endphp
3954

@@ -189,6 +204,31 @@ class="inline-block rounded-full py-2.5 px-6 border border-[#1C4DA1] text-[#1C4D
189204
</section>
190205
@endsection
191206

207+
@push('scripts')
208+
<script>
209+
(function () {
210+
document.querySelectorAll('#codeweek-training-dynamic-subpage svg').forEach(function (svg) {
211+
if (svg.getAttribute('viewBox')) {
212+
return;
213+
}
214+
var w = svg.getAttribute('width');
215+
var h = svg.getAttribute('height');
216+
if (!w || !h) {
217+
return;
218+
}
219+
var wn = parseFloat(w);
220+
var hn = parseFloat(h);
221+
if (!(wn > 0 && hn > 0)) {
222+
return;
223+
}
224+
svg.setAttribute('viewBox', '0 0 ' + wn + ' ' + hn);
225+
svg.removeAttribute('width');
226+
svg.removeAttribute('height');
227+
});
228+
})();
229+
</script>
230+
@endpush
231+
192232
@if($anchorOffset > 0)
193233
@push('scripts')
194234
<script>

routes/web.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
use App\Http\Controllers\CertificateBackendController;
6464
use App\Http\Controllers\ToolkitsController;
6565
use App\Http\Controllers\TrainingController;
66+
use App\Http\Controllers\TrainingRoadmapPdfController;
6667
use App\Http\Controllers\UnsubscribeController;
6768
use App\Http\Controllers\UserController;
6869
use App\Http\Controllers\VolunteerController;
@@ -340,6 +341,10 @@
340341
Route::get('/training-preview/{trainingResource}', [TrainingController::class, 'preview'])
341342
->middleware('signed')
342343
->name('training.preview');
344+
Route::get('/training/embedded-roadmap.pdf', [TrainingRoadmapPdfController::class, 'proxyPdf'])
345+
->name('training.embedded_pdf.roadmap');
346+
Route::get('/training/roadmap-pdf-viewer', [TrainingRoadmapPdfController::class, 'viewer'])
347+
->name('training.roadmap_pdf_viewer');
343348
Route::get('/training/{slug}', [TrainingController::class, 'show'])->name('training.dynamic.show');
344349
Route::post('/contact-submit', [ContactFormController::class, 'submit'])
345350
->middleware('throttle:5,1') // 5 requests per minute per IP

0 commit comments

Comments
 (0)