Skip to content

Commit fb78f2c

Browse files
committed
feat(training): PDF.js roadmap viewer and responsive inline SVGs
- Add same-origin PDF proxy and minimal PDF.js viewer for DDP roadmap embed - Fall back to direct PDF iframe for other roadmap URLs - Scale oversized inline SVGs (infer viewBox from width/height) and constrain with CSS - Allow long links in rich content to wrap (break-words) Made-with: Cursor
1 parent 34c52a5 commit fb78f2c

5 files changed

Lines changed: 153 additions & 4 deletions

File tree

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+
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44
@php
55
// Strip any fragment from stored URL for the "open in new tab" link.
66
$tabUrl = \Illuminate\Support\Str::before($url, '#');
7-
// Adobe-style PDF open parameters: hide toolbar/side panes in many Chromium viewers.
8-
// Safari / iOS often ignore these; users can use "Open in new tab" for the native viewer.
9-
$embedSrc = $tabUrl.'#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
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;
1013
@endphp
1114
<div class="w-full max-w-full my-6 rounded-xl overflow-hidden border border-slate-200 bg-slate-100 shadow-sm">
1215
<iframe
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: 26 additions & 1 deletion
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";
@@ -189,6 +189,31 @@ class="inline-block rounded-full py-2.5 px-6 border border-[#1C4DA1] text-[#1C4D
189189
</section>
190190
@endsection
191191

192+
@push('scripts')
193+
<script>
194+
(function () {
195+
document.querySelectorAll('#codeweek-training-dynamic-subpage svg').forEach(function (svg) {
196+
if (svg.getAttribute('viewBox')) {
197+
return;
198+
}
199+
var w = svg.getAttribute('width');
200+
var h = svg.getAttribute('height');
201+
if (!w || !h) {
202+
return;
203+
}
204+
var wn = parseFloat(w);
205+
var hn = parseFloat(h);
206+
if (!(wn > 0 && hn > 0)) {
207+
return;
208+
}
209+
svg.setAttribute('viewBox', '0 0 ' + wn + ' ' + hn);
210+
svg.removeAttribute('width');
211+
svg.removeAttribute('height');
212+
});
213+
})();
214+
</script>
215+
@endpush
216+
192217
@if($anchorOffset > 0)
193218
@push('scripts')
194219
<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)