Skip to content
Merged

Dev #3530

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions app/Http/Controllers/TrainingRoadmapPdfController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class TrainingRoadmapPdfController extends Controller
{
/**
* Public roadmap PDF used by the Discover Digital Programme embed.
* Kept allowlisted here so the proxy cannot be abused as an open redirect.
*/
private const ROADMAP_SOURCE = 'https://codeweek-resources.s3.eu-west-1.amazonaws.com/+discover-digital-toolkit/DDP_toolkit_roadmap.pdf';

/**
* Same-origin PDF bytes for PDF.js (avoids cross-origin fetch issues).
*/
public function proxyPdf()
{
$bytes = Cache::remember(
'training_embedded_roadmap_pdf_v1',
3600,
function () {
$response = Http::timeout(120)->get(self::ROADMAP_SOURCE);

if (! $response->successful()) {
abort(502, 'Unable to load roadmap PDF.');
}

return $response->body();
}
);

return response($bytes, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="DDP_toolkit_roadmap.pdf"',
'Cache-Control' => 'public, max-age=3600',
]);
}

/**
* Minimal PDF.js viewer (no site chrome) for iframe embedding on training pages.
*/
public function viewer()
{
return response()
->view('training.roadmap-pdfjs')
->header('X-Frame-Options', 'SAMEORIGIN');
}
}
32 changes: 31 additions & 1 deletion app/Nova/TrainingResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL;
use Laravel\Nova\Fields\Boolean;
use Laravel\Nova\Fields\FormData;
use Laravel\Nova\Fields\ID;
use Laravel\Nova\Fields\Number;
use Laravel\Nova\Fields\Select;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Fields\Trix;
Expand Down Expand Up @@ -153,10 +155,38 @@ public function fields(Request $request): array
->nullable()
->help('Optional scroll offset in pixels for in-page anchor links (useful with sticky headers).'),

Select::make('Roadmap embed format', 'roadmap_embed_kind')
->options([
'pdf' => 'PDF (inline viewer)',
'svg' => 'SVG (paste markup below)',
'none' => 'None (remove placeholder output)',
])
->default('pdf')
->help('Use [[embed_roadmap_pdf]] or [[embed_roadmap]] in Content where the roadmap should appear.'),

Text::make('Roadmap PDF embed URL', 'roadmap_pdf_embed_url')
->nullable()
->rules('nullable', 'url')
->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).'),
->dependsOn(['roadmap_embed_kind'], function (Text $field, NovaRequest $request, FormData $formData) {
if (($formData->roadmap_embed_kind ?? 'pdf') === 'pdf') {
$field->show();
} else {
$field->hide();
}
})
->help('HTTPS URL to the roadmap PDF. Used when format is PDF.'),

Textarea::make('Roadmap SVG', 'roadmap_svg')
->nullable()
->rows(10)
->dependsOn(['roadmap_embed_kind'], function (Textarea $field, NovaRequest $request, FormData $formData) {
if (($formData->roadmap_embed_kind ?? 'pdf') === 'svg') {
$field->show();
} else {
$field->hide();
}
})
->help('Full <svg>...</svg> markup from your design tool. Used when format is SVG.'),

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

Expand Down
2 changes: 2 additions & 0 deletions app/TrainingResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class TrainingResource extends Model
'about_box_section',
'anchor_offset',
'roadmap_pdf_embed_url',
'roadmap_embed_kind',
'roadmap_svg',
'button_text',
'button_url',
'secondary_button_text',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('training_resources', function (Blueprint $table) {
$table->string('roadmap_embed_kind', 16)->default('pdf')->after('roadmap_pdf_embed_url');
$table->mediumText('roadmap_svg')->nullable()->after('roadmap_embed_kind');
});
}

public function down(): void
{
Schema::table('training_resources', function (Blueprint $table) {
$table->dropColumn(['roadmap_embed_kind', 'roadmap_svg']);
});
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ public function run(): void
HTML,
'anchor_offset' => 120,
'roadmap_pdf_embed_url' => 'https://codeweek-resources.s3.eu-west-1.amazonaws.com/+discover-digital-toolkit/DDP_toolkit_roadmap.pdf',
'roadmap_embed_kind' => 'pdf',
'roadmap_svg' => null,
'third_button_text' => 'Register an activity',
'third_button_url' => 'https://codeweek.eu/add?skip=1',
'meta_title' => 'Discover Digital Programme - Toolkit',
Expand Down
19 changes: 15 additions & 4 deletions resources/views/training/partials/roadmap-pdf-embed.blade.php
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
@props([
'url',
])
@php
// Strip any fragment from stored URL for the "open in new tab" link.
$tabUrl = \Illuminate\Support\Str::before($url, '#');
// Same-origin PDF.js viewer for the allowlisted DDP roadmap (see TrainingRoadmapPdfController).
// Other URLs keep the direct PDF iframe with viewer chrome hints.
$ddpRoadmapBase = 'https://codeweek-resources.s3.eu-west-1.amazonaws.com/+discover-digital-toolkit/DDP_toolkit_roadmap.pdf';
$usePdfjsViewer = strtolower(rtrim($tabUrl, '/')) === strtolower(rtrim($ddpRoadmapBase, '/'));
$fragment = '#toolbar=0&navpanes=0&scrollbar=1&view=FitH';
$embedSrc = $usePdfjsViewer ? route('training.roadmap_pdf_viewer') : $tabUrl.$fragment;
@endphp
<div class="w-full max-w-full my-6 rounded-xl overflow-hidden border border-slate-200 bg-slate-100 shadow-sm">
<iframe
title="{{ __('Roadmap (PDF)') }}"
src="{{ $url }}"
class="w-full border-0 block"
style="height: min(75vh, 880px); min-height: 480px;"
src="{{ $embedSrc }}"
class="w-full border-0 block max-w-full"
style="width: 100%; height: min(85dvh, 920px); min-height: min(60dvh, 520px);"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
></iframe>
</div>
<p class="text-sm mt-2 mb-0 text-[#333E48]">
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" class="text-dark-blue underline font-medium">
<a href="{{ $tabUrl }}" target="_blank" rel="noopener noreferrer" class="text-dark-blue underline font-medium">
{{ __('Open roadmap PDF in a new tab') }}
</a>
— {{ __('if the preview does not load in your browser.') }}
Expand Down
6 changes: 6 additions & 0 deletions resources/views/training/partials/roadmap-svg-embed.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@props([
'svg',
])
<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">
{!! $svg !!}
</div>
65 changes: 65 additions & 0 deletions resources/views/training/roadmap-pdfjs.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{ __('Roadmap') }}</title>
<style>
html, body { margin: 0; padding: 0; background: #f1f5f9; min-height: 100%; }
#pdf-scroll { max-width: 100%; padding: 8px; box-sizing: border-box; overflow-x: hidden; overflow-y: auto; }
#pdf-error { color: #b91c1c; padding: 1rem; font-family: system-ui, sans-serif; font-size: 0.9rem; }
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; }
</style>
</head>
<body>
<div id="pdf-error" role="alert"></div>
<div id="pdf-scroll"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
(async function () {
var pdfUrl = @json(url(route('training.embedded_pdf.roadmap')));
var pdfjsLib = window.pdfjsLib || window['pdfjs-dist/build/pdf'];
var container = document.getElementById('pdf-scroll');
var err = document.getElementById('pdf-error');

if (!pdfjsLib || typeof pdfjsLib.getDocument !== 'function') {
err.textContent = 'PDF viewer failed to load.';
return;
}

pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';

try {
var pdf = await pdfjsLib.getDocument({ url: pdfUrl }).promise;

async function renderAll() {
container.innerHTML = '';
var cw = Math.max(200, container.clientWidth || window.innerWidth);
for (var p = 1; p <= pdf.numPages; p++) {
var page = await pdf.getPage(p);
var base = page.getViewport({ scale: 1 });
var scale = Math.min(2.2, Math.max(0.4, (cw - 16) / base.width));
var viewport = page.getViewport({ scale: scale });
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d', { alpha: false });
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
container.appendChild(canvas);
}
}

await renderAll();
var t;
window.addEventListener('resize', function () {
clearTimeout(t);
t = setTimeout(function () { renderAll(); }, 200);
});
} catch (e) {
err.textContent = (e && e.message) ? e.message : 'Could not open PDF.';
}
})();
</script>
</body>
</html>
58 changes: 49 additions & 9 deletions resources/views/training/show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
$pageDescription = $trainingResource->meta_description ?: $fallbackDescription;

$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";
$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";
$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";
$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";
$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";
$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";
Expand All @@ -26,14 +26,29 @@

$renderedContent = $trainingResource->content ?? '';
$roadmapEmbedUrl = trim((string) ($trainingResource->roadmap_pdf_embed_url ?? ''));
if ($roadmapEmbedUrl !== '' && str_contains($renderedContent, '[[embed_roadmap_pdf]]')) {
$renderedContent = str_replace(
'[[embed_roadmap_pdf]]',
view('training.partials.roadmap-pdf-embed', ['url' => $roadmapEmbedUrl])->render(),
$renderedContent
);
} elseif (str_contains($renderedContent, '[[embed_roadmap_pdf]]')) {
$renderedContent = str_replace('[[embed_roadmap_pdf]]', '', $renderedContent);
$roadmapEmbedKind = strtolower(trim((string) ($trainingResource->roadmap_embed_kind ?? 'pdf')));
if (! in_array($roadmapEmbedKind, ['pdf', 'svg', 'none'], true)) {
$roadmapEmbedKind = 'pdf';
}
$roadmapSvg = trim((string) ($trainingResource->roadmap_svg ?? ''));

$roadmapPlaceholders = ['[[embed_roadmap_pdf]]', '[[embed_roadmap]]'];
$hasRoadmapPlaceholder = str_contains($renderedContent, '[[embed_roadmap_pdf]]')
|| str_contains($renderedContent, '[[embed_roadmap]]');

if ($hasRoadmapPlaceholder) {
$roadmapEmbedHtml = '';
if ($roadmapEmbedKind === 'pdf' && $roadmapEmbedUrl !== '') {
$roadmapEmbedHtml = view('training.partials.roadmap-pdf-embed', ['url' => $roadmapEmbedUrl])->render();
} elseif ($roadmapEmbedKind === 'svg' && $roadmapSvg !== '') {
$roadmapEmbedHtml = view('training.partials.roadmap-svg-embed', ['svg' => $roadmapSvg])->render();
}

foreach ($roadmapPlaceholders as $token) {
if (str_contains($renderedContent, $token)) {
$renderedContent = str_replace($token, $roadmapEmbedHtml, $renderedContent);
}
}
}
@endphp

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

@push('scripts')
<script>
(function () {
document.querySelectorAll('#codeweek-training-dynamic-subpage svg').forEach(function (svg) {
if (svg.getAttribute('viewBox')) {
return;
}
var w = svg.getAttribute('width');
var h = svg.getAttribute('height');
if (!w || !h) {
return;
}
var wn = parseFloat(w);
var hn = parseFloat(h);
if (!(wn > 0 && hn > 0)) {
return;
}
svg.setAttribute('viewBox', '0 0 ' + wn + ' ' + hn);
svg.removeAttribute('width');
svg.removeAttribute('height');
});
})();
</script>
@endpush

@if($anchorOffset > 0)
@push('scripts')
<script>
Expand Down
5 changes: 5 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
use App\Http\Controllers\CertificateBackendController;
use App\Http\Controllers\ToolkitsController;
use App\Http\Controllers\TrainingController;
use App\Http\Controllers\TrainingRoadmapPdfController;
use App\Http\Controllers\UnsubscribeController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\VolunteerController;
Expand Down Expand Up @@ -340,6 +341,10 @@
Route::get('/training-preview/{trainingResource}', [TrainingController::class, 'preview'])
->middleware('signed')
->name('training.preview');
Route::get('/training/embedded-roadmap.pdf', [TrainingRoadmapPdfController::class, 'proxyPdf'])
->name('training.embedded_pdf.roadmap');
Route::get('/training/roadmap-pdf-viewer', [TrainingRoadmapPdfController::class, 'viewer'])
->name('training.roadmap_pdf_viewer');
Route::get('/training/{slug}', [TrainingController::class, 'show'])->name('training.dynamic.show');
Route::post('/contact-submit', [ContactFormController::class, 'submit'])
->middleware('throttle:5,1') // 5 requests per minute per IP
Expand Down
Loading