Skip to content

Commit 8d44d6a

Browse files
committed
Added Cover option
1 parent 4782b34 commit 8d44d6a

10 files changed

Lines changed: 192 additions & 13 deletions

File tree

assets/app.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ import './styles/app.css';
44
import 'bootstrap';
55

66
const MAX_FILE_SIZE = 8 * 1024 * 1024; // 8 MB, matches server-side limit
7+
const MAX_COVER_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB, matches server-side limit
8+
9+
function readFileAsDataUrl(file) {
10+
return new Promise((resolve, reject) => {
11+
const reader = new FileReader();
12+
reader.onload = () => resolve(reader.result);
13+
reader.onerror = () => reject(new Error(`Unable to read "${file.name}".`));
14+
reader.readAsDataURL(file);
15+
});
16+
}
717

818
/* ── Theme toggle ───────────────────────────────────────────── */
919
function initTheme() {
@@ -134,6 +144,20 @@ function initUploadForm() {
134144
return;
135145
}
136146

147+
const coverImageFile = document.getElementById('cover_image')?.files?.[0] ?? null;
148+
if (coverImageFile && coverImageFile.size > MAX_COVER_IMAGE_SIZE) {
149+
showError(`"${coverImageFile.name}" exceeds the 5 MB limit for the cover image.`);
150+
return;
151+
}
152+
153+
let coverImage = '';
154+
try {
155+
coverImage = coverImageFile ? await readFileAsDataUrl(coverImageFile) : '';
156+
} catch (err) {
157+
showError(err.message);
158+
return;
159+
}
160+
137161
const payload = {
138162
gpx: gpxContent,
139163
referenceCode,
@@ -149,6 +173,9 @@ function initUploadForm() {
149173
pagebreak: checked('pagebreak'),
150174
images: checked('images'),
151175
sort_by: checked('sort') ? (form.querySelector('input[name="sort_by"]:checked')?.value ?? '') : '',
176+
cover_title: document.getElementById('cover_title')?.value ?? '',
177+
cover_description: document.getElementById('cover_description')?.value ?? '',
178+
cover_image: coverImage,
152179
};
153180

154181
const originalLabel = createBtn.textContent;

public/design/roadbook-modern.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,34 @@ body, td, pre {
2626
clear: both;
2727
height: 0;
2828
}
29+
.coverPage {
30+
display: flex;
31+
flex-direction: column;
32+
align-items: center;
33+
justify-content: center;
34+
text-align: center;
35+
min-height: 90vh;
36+
break-after: page;
37+
}
38+
.coverImage {
39+
max-width: 100%;
40+
max-height: 60vh;
41+
height: auto;
42+
margin-bottom: 1cm;
43+
border: 0.4mm solid var(--rule);
44+
}
45+
.coverTitle {
46+
font-family: 'Trebuchet MS', Helvetica, Arial, sans-serif;
47+
font-size: 24pt;
48+
font-weight: 700;
49+
color: var(--accent);
50+
margin: 0 0 0.5cm 0;
51+
}
52+
.coverDescription {
53+
font-size: 12pt;
54+
color: var(--ink-soft);
55+
max-width: 80%;
56+
}
2957
.separator {
3058
margin: 0.7cm 0;
3159
border: 0;

public/design/roadbook.css

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,29 @@ body, td, pre {
99
clear: both;
1010
height: 0;
1111
}
12+
.coverPage {
13+
display: flex;
14+
flex-direction: column;
15+
align-items: center;
16+
justify-content: center;
17+
text-align: center;
18+
min-height: 90vh;
19+
break-after: page;
20+
}
21+
.coverImage {
22+
max-width: 100%;
23+
max-height: 60vh;
24+
height: auto;
25+
margin-bottom: 1cm;
26+
}
27+
.coverTitle {
28+
font-size: 22pt;
29+
margin: 0 0 0.5cm 0;
30+
}
31+
.coverDescription {
32+
font-size: 12pt;
33+
max-width: 80%;
34+
}
1235
.separator {
1336
margin: 0.5cm 0;
1437
border: 0.5mm solid #000;

src/Command/PurgeRoadbooksCommand.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
4141

4242
$files = array_merge(
4343
glob($this->roadbookDir . '/*.{gpx,html,json}', GLOB_BRACE) ?: [],
44+
glob($this->roadbookDir . '/*-cover.*') ?: [],
4445
glob($this->roadbookDir . '/pdf/*.pdf') ?: [],
4546
);
4647

src/Controller/GeoroadBookController.php

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020

2121
class GeoroadBookController extends AbstractController
2222
{
23+
private const int COVER_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
24+
25+
/** @var array<string, string> */
26+
private const array COVER_IMAGE_MIME_EXTENSIONS = [
27+
'image/jpeg' => 'jpg',
28+
'image/png' => 'png',
29+
'image/webp' => 'webp',
30+
];
31+
2332
/**
2433
* @param array<string, string> $locales
2534
* @param list<string> $availableSorts
@@ -101,10 +110,13 @@ public function upload(Request $request): JsonResponse
101110
{
102111
$payload = $request->getPayload();
103112

104-
$gpx = (string) $payload->get('gpx', '');
105-
$referenceCode = (string) $payload->get('referenceCode', '');
106-
$locale = $payload->get('locale');
107-
$themeKey = (string) $payload->get('theme', array_key_first($this->themes));
113+
$gpx = (string) $payload->get('gpx', '');
114+
$referenceCode = (string) $payload->get('referenceCode', '');
115+
$locale = $payload->get('locale');
116+
$themeKey = (string) $payload->get('theme', array_key_first($this->themes));
117+
$coverTitle = trim((string) $payload->get('cover_title', ''));
118+
$coverDescription = trim((string) $payload->get('cover_description', ''));
119+
$coverImageDataUrl = trim((string) $payload->get('cover_image', ''));
108120

109121
if ($gpx === '' && $referenceCode === '') {
110122
return $this->json(['success' => false, 'message' => 'A GPX file or a Pocket Query is missing.']);
@@ -122,6 +134,18 @@ public function upload(Request $request): JsonResponse
122134
$themeKey = array_key_first($this->themes);
123135
}
124136

137+
$coverImage = null;
138+
if ($coverImageDataUrl !== '') {
139+
if (!preg_match('#^data:(image/(?:jpeg|png|webp));base64,(.+)$#s', $coverImageDataUrl, $m)) {
140+
return $this->json(['success' => false, 'message' => 'Cover image format is not supported. Use JPEG, PNG or WebP.']);
141+
}
142+
$binary = base64_decode($m[2], true);
143+
if ($binary === false || $binary === '' || strlen($binary) > self::COVER_IMAGE_MAX_BYTES) {
144+
return $this->json(['success' => false, 'message' => 'Cover image is invalid or exceeds the 5 MB limit.']);
145+
}
146+
$coverImage = ['binary' => $binary, 'extension' => self::COVER_IMAGE_MIME_EXTENSIONS[$m[1]]];
147+
}
148+
125149
if ($referenceCode !== '') {
126150
try {
127151
$gpx = $this->downloadPocketQuery($referenceCode);
@@ -173,6 +197,15 @@ public function upload(Request $request): JsonResponse
173197
return $this->json(['success' => false, 'message' => 'Unable to save the GPX file.']);
174198
}
175199

200+
$cover = null;
201+
if ($coverTitle !== '') {
202+
$cover = [
203+
'title' => $coverTitle,
204+
'description' => $coverDescription !== '' ? $coverDescription : null,
205+
'image' => $coverImage !== null ? $roadbook->saveCoverImage($coverImage['binary'], $coverImage['extension']) : null,
206+
];
207+
}
208+
176209
$options = [
177210
'display_note' => $bool($payload->get('note')),
178211
'display_long_desc' => $bool($payload->get('long_desc')),
@@ -206,7 +239,7 @@ public function upload(Request $request): JsonResponse
206239
}
207240
}
208241

209-
$roadbook->setContent($this->renderer->render($caches, $locale, $options), $locale)->cleanHtml();
242+
$roadbook->setContent($this->renderer->render($caches, $locale, $options, $cover), $locale)->cleanHtml();
210243

211244
if ($displayToc) {
212245
$roadbook->addToc();

src/Roadbook/Roadbook.php

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ public function getPdfFile(): string
6666
return $this->roadbookDir . sprintf('/pdf/%s.pdf', $this->id);
6767
}
6868

69+
/**
70+
* Saves the cover page image (event flyer, logo, etc.) and returns its
71+
* web-accessible path. public/roadbook is served directly by nginx, so
72+
* no dedicated route is needed to display it (raw view, PDF export,
73+
* zip export all reach it the same way as /img and /images assets).
74+
*/
75+
public function saveCoverImage(string $binary, string $extension): string
76+
{
77+
$filename = sprintf('%s-cover.%s', $this->id, $extension);
78+
$this->saveFile($this->roadbookDir . '/' . $filename, $binary);
79+
80+
return '/roadbook/' . $filename;
81+
}
82+
83+
/**
84+
* Finds the cover image previously saved by saveCoverImage(), if any.
85+
*/
86+
private function findCoverImageFile(): ?string
87+
{
88+
$matches = glob($this->roadbookDir . '/' . $this->id . '-cover.*');
89+
90+
return $matches !== false && $matches !== [] ? $matches[0] : null;
91+
}
92+
6993
/**
7094
* Renders the raw roadbook page to PDF, either through the WeasyPrint
7195
* HTTP sidecar (dev/Docker) or the standalone `weasyprint` binary reading
@@ -186,11 +210,11 @@ public function buildZip(string $publicDir): string
186210
throw new \RuntimeException('Unable to create the zip archive.');
187211
}
188212

189-
// The generated HTML uses absolute asset paths (/img, /images); the
190-
// archive is self-contained, so rewrite them relative to its layout.
213+
// The generated HTML uses absolute asset paths (/img, /images, /roadbook);
214+
// the archive is self-contained, so rewrite them relative to its layout.
191215
$content = str_replace(
192-
['src="/img/', 'src="/images/'],
193-
['src="../img/', 'src="../images/'],
216+
['src="/img/', 'src="/images/', 'src="/roadbook/'],
217+
['src="../img/', 'src="../images/', 'src="'],
194218
(string) file_get_contents($this->getHtmlFile()),
195219
);
196220

@@ -206,6 +230,11 @@ public function buildZip(string $publicDir): string
206230
$zip->addFromString('roadbook/' . $this->id . '.html', $html);
207231
$zip->addFile($publicDir . '/design/' . $themeCss, 'design/' . $themeCss);
208232

233+
$coverImage = $this->findCoverImageFile();
234+
if ($coverImage !== null) {
235+
$zip->addFile($coverImage, 'roadbook/' . basename($coverImage));
236+
}
237+
209238
foreach (['img', 'images'] as $imageDir) {
210239
$dir = $publicDir . '/' . $imageDir;
211240
if (!is_dir($dir)) {
@@ -237,6 +266,11 @@ public function delete(): bool
237266
@unlink($file);
238267
}
239268

269+
$coverImage = $this->findCoverImageFile();
270+
if ($coverImage !== null) {
271+
@unlink($coverImage);
272+
}
273+
240274
@unlink($this->getPdfFile());
241275

242276
return true;

src/Roadbook/RoadbookRenderer.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ public function __construct(
2727
}
2828

2929
/**
30-
* @param list<Geocache> $caches
31-
* @param array<string, bool|string> $options display_note, display_long_desc, display_hint,
32-
* display_waypoints, display_spoilers, display_logs, pagebreak
30+
* @param list<Geocache> $caches
31+
* @param array<string, bool|string> $options display_note, display_long_desc, display_hint,
32+
* display_waypoints, display_spoilers, display_logs, pagebreak
33+
* @param array{title: string, description: ?string, image: ?string}|null $cover optional cover page (event title, description, flyer image)
3334
*/
34-
public function render(array $caches, string $locale, array $options): string
35+
public function render(array $caches, string $locale, array $options, ?array $cover = null): string
3536
{
3637
$items = [];
3738
foreach ($caches as $cache) {
@@ -58,6 +59,7 @@ public function render(array $caches, string $locale, array $options): string
5859
'options' => $options,
5960
't' => $this->locales->texts($locale),
6061
'icons' => $this->icons,
62+
'cover' => $cover,
6163
]);
6264
}
6365

templates/index.html.twig

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,28 @@
151151
</div>
152152
</div>
153153

154+
{# Cover Page #}
155+
<div class="option-group-card mt-4" style="max-width: 960px; margin-left: auto; margin-right: auto;">
156+
<div class="option-group-title mb-3">
157+
<i class="bi bi-image"></i> Cover page <span class="text-muted small fw-normal">(optional, e.g. for an upcoming event)</span>
158+
</div>
159+
<div class="row g-3">
160+
<div class="col-md-6">
161+
<label class="form-label form-label-mono" for="cover_title">Title</label>
162+
<input type="text" class="form-control" name="cover_title" id="cover_title" maxlength="120" placeholder="e.g. Summer Geocaching Meetup 2026">
163+
</div>
164+
<div class="col-md-6">
165+
<label class="form-label form-label-mono" for="cover_image">Image</label>
166+
<input type="file" class="form-control" name="cover_image" id="cover_image" accept="image/jpeg,image/png,image/webp">
167+
<div class="small text-muted mt-1">JPEG, PNG or WebP, 5 MB max.</div>
168+
</div>
169+
<div class="col-12">
170+
<label class="form-label form-label-mono" for="cover_description">Description</label>
171+
<textarea class="form-control" name="cover_description" id="cover_description" rows="3" maxlength="500" placeholder="Date, meeting point, anything you'd like your guests to know…"></textarea>
172+
</div>
173+
</div>
174+
</div>
175+
154176
{# Advanced Options #}
155177
<div class="option-group-card mt-4" style="max-width: 960px; margin-left: auto; margin-right: auto;">
156178
<div class="d-flex align-items-center justify-content-between">
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<section class="coverPage">
2+
{% if cover.image %}<img src="{{ cover.image }}" alt="" class="coverImage"/>{% endif %}
3+
<h1 class="coverTitle">{{ cover.title }}</h1>
4+
{% if cover.description %}<p class="coverDescription">{{ cover.description|nl2br }}</p>{% endif %}
5+
</section>
6+
<p class="pagebreak"></p>

templates/roadbook/document.html.twig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
<meta charset="utf-8"/>
55
</head>
66
<body>
7+
{% if cover is defined and cover %}
8+
{{- include('roadbook/_cover.html.twig') }}
9+
{% endif %}
710
{% for item in items %}
811
{%- if not loop.first %}
912
{%- if options.pagebreak %}<p class="pagebreak"></p>{% else %}<hr class="separator"/>{% endif %}

0 commit comments

Comments
 (0)