feat(events): gallery QR code + printable table-card/poster PDFs (#847)

* feat(events): gallery QR code + printable table-card/poster PDFs (#836)

- GET /api/admin/events/:id/qr — share-link QR as PNG (128-2048px) or
  SVG, inline or attachment; adminAuth + events.view + ownership.
- GET /api/admin/events/:id/qr-print — pdfkit-rendered A6 table card /
  A4 poster with event name, QR, localized caption (8 locales; Cyrillic
  falls back to English — built-in Helvetica has no Cyrillic glyphs) and
  the share URL as footer.
- Event detail: QR section in ShareLinkCard with live preview (blob
  fetch — Bearer auth) and PNG/SVG/table-card/poster downloads; print
  language follows the admin UI language. i18n keys in all 8 locales.
- qrcode + pdfkit were already dependencies (MFA / CRM PDFs).

* fix(events): QR origin fallback, Unicode PDF font, bounded layout, stale-preview guard (codex review of #847)

- QR URLs: prefer the configured public base, but fall back to the admin
  browser's origin (passed as ?origin=, validated) when the base is
  missing or localhost — mirrors buildShareLinkUrl so the QR encodes the
  same URL the card displays instead of an unusable localhost target.
- PDFs render with the bundled IBM Plex Sans TTFs (Latin+Cyrillic+Greek)
  instead of WinAnsi-only Helvetica: Cyrillic event names no longer
  silently disappear, and the caption's English-fallback hack is gone.
- Fixed vertical layout: title gets a bounded two-line ellipsis region
  and all positions derive from constants, so long event names can't
  push the QR/caption over the footer; URL footer bounded too.
- ShareLinkCard preview: stale-response guard — a late blob response
  after unmount/event-switch is revoked instead of leaking and
  overwriting the newer event's QR.

* fix(events): bundle complete IBM Plex Sans for QR PDFs + IPv6 loopback fallback (codex review of #847, round 2)

Round 2 caught that the pre-existing assets/fonts/IBM-Plex-Sans/ files
are 270-glyph Latin SUBSETS — my round-1 font swap didn't actually fix
Cyrillic titles and regressed the ru caption. Now bundling the complete
IBM Plex Sans 400/700 TTFs (1019 glyphs, Latin+Cyrillic+Greek — cmap
verified via fontkit, rendering verified on a generated PDF) under
assets/fonts/IBM-Plex-Sans-Full/ with the OFL license alongside.
~400 KB total; source: IBM/plex release zip @ibm/[email protected].

Also: LOCAL_BASE_RE now recognizes IPv6 loopback ([::1]) so a
FRONTEND_URL of http://[::1]:3000 falls back to the browser origin like
the frontend's own URL logic does.

Note for a follow-up: the CRM invoice/quote PDFs use the same Latin-only
subsets and share the Cyrillic gap.

* fix(events): responsive QR card that survives preview failures (codex review of #847, round 3)

- The QR section keys off share-link availability instead of a loaded
  preview: a transient failure of the preview request no longer hides
  every download button until reload; a placeholder tile renders in
  place of the image.
- Preview + actions stack on phone widths and the button grid drops to
  one column below sm, so 'Tischkarte (A6)'-length labels don't
  overflow.

* fix(events): QR encodes the stored share_link + spec quiet zone (codex review of #847, confirmation round)

- The QR target is now the STORED share_link — exactly what the card
  displays and the admin copies. Rebuilding from current slug/token/
  short-URL setting could diverge for legacy absolute links or events
  created under a different short-URL setting; a printed QR encoding a
  different URL than the card is a permanent mistake. Rebuild remains
  only as fallback when no share_link is stored.
- QR margin back to the library's 4-module default for all generated
  assets — the spec's quiet zone; margin 2 risks scan failures when the
  printout sits against colored surroundings.

* fix(events): bare share_link tokens resolve as /gallery/<token> in QR URLs (codex review of #847, final round)

Quote-/contract-converted events persist share_link as the raw token —
the frontend's buildShareLinkUrl prefixes those with /gallery/, but the
QR path normalization only added a leading slash, encoding
<origin>/<token> into every image/PDF for such events. Now mirrors the
frontend exactly.

* test(events): 30s timeout for the print-PDF cases (CI fix)

The poster PDF now embeds the full IBM Plex Sans TTFs (~200 KB each);
font parsing + subsetting exceeds jest's 5s default on slower CI
runners — the suite went red on exactly that test after the font
commit.
This commit is contained in:
Paul Nothaft
2026-07-19 22:35:44 +02:00
committed by GitHub
parent 613133c29c
commit 60cdd07085
16 changed files with 547 additions and 3 deletions
@@ -0,0 +1,122 @@
/**
* HTTP tests for the gallery QR endpoints (#836):
* GET /api/admin/events/:id/qr (PNG / SVG)
* GET /api/admin/events/:id/qr-print (table-card / poster PDF)
* Same real-SQLite harness as adminEvents.smoke.test.js.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-qr-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-qr-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, adminId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'QR Test Wedding',
event_date: '2026-05-29',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: adminId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('admin event QR endpoints', () => {
let db; let cleanup; let app; let adminId; let token;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => { await db('events').del(); });
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
it('401s without an admin token', async () => {
const eventId = await insertEvent(db, adminId);
const res = await request(app).get(`/api/admin/events/${eventId}/qr`);
expect(res.status).toBe(401);
});
it('returns a PNG QR by default', async () => {
const eventId = await insertEvent(db, adminId);
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`)).buffer();
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
// PNG magic bytes
expect(res.body.slice(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
});
it('returns an SVG QR when requested', async () => {
const eventId = await insertEvent(db, adminId);
// supertest doesn't text-parse image/svg+xml — buffer and decode manually.
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?format=svg`)).buffer();
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/image\/svg\+xml/);
expect(Buffer.from(res.body).toString('utf8')).toContain('<svg');
});
it('sets attachment disposition with download=1', async () => {
const eventId = await insertEvent(db, adminId);
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr?download=1`)).buffer();
expect(res.headers['content-disposition']).toMatch(/^attachment/);
});
// 30s: the print PDFs embed the full IBM Plex Sans TTFs (~200 KB each) —
// font parsing + subsetting exceeds jest's 5s default on slower CI runners.
it.each(['table-card', 'poster'])('renders the %s print PDF', async (template) => {
const eventId = await insertEvent(db, adminId);
const res = await auth(
request(app).get(`/api/admin/events/${eventId}/qr-print?template=${template}&lang=de`)
).buffer();
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('application/pdf');
expect(res.body.slice(0, 4).toString()).toBe('%PDF');
}, 30000);
it('409s when the event has no share link', async () => {
// events.share_link is NOT NULL — an empty string is the closest real-world
// "no share link" shape (no token extractable from it either).
const eventId = await insertEvent(db, adminId, { share_link: '', share_token: null });
const res = await auth(request(app).get(`/api/admin/events/${eventId}/qr`));
expect(res.status).toBe(409);
});
it('404s for a non-existent event', async () => {
const res = await auth(request(app).get('/api/admin/events/999999/qr'));
expect(res.status).toBe(404);
});
});
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1
View File
@@ -13,5 +13,6 @@ require('./slideshow')(router);
require('./resets')(router); require('./resets')(router);
require('./archiveBulk')(router); require('./archiveBulk')(router);
require('./logo')(router); require('./logo')(router);
require('./qr')(router);
module.exports = router; module.exports = router;
+183
View File
@@ -0,0 +1,183 @@
// Gallery QR codes (#836): QR image (PNG/SVG) for the event's share link and
// print-ready PDF templates (table card A6, poster A4) built with pdfkit.
// Registered after ./logo in ./index.js — all routes are '/:id/...' literals,
// so registration order relative to the other sub-modules is not sensitive.
const path = require('path');
const QRCode = require('qrcode');
const PDFDocument = require('pdfkit');
const { db } = require('../../database/db');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission } = require('../../middleware/permissions');
const { requireEventOwnership } = require('../../middleware/ownership');
const { buildShareLinkVariants, getEventShareToken } = require('../../services/shareLinkService');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const logger = require('../../utils/logger');
const { errorResponse } = require('../../utils/routeHelpers');
// Caption under the QR on the print templates, in the admin's UI language
// (passed as ?lang= by the frontend). Mirrors the 8 gallery locales.
const PRINT_CAPTIONS = {
en: 'Scan to view & share your photos',
de: 'Scannen, um Fotos anzusehen & zu teilen',
es: 'Escanea para ver y compartir tus fotos',
fr: 'Scannez pour voir et partager vos photos',
nl: 'Scan om fotos te bekijken & te delen',
pt: 'Escaneie para ver e compartilhar suas fotos',
ru: 'Отсканируйте, чтобы посмотреть и поделиться фото',
sl: 'Skenirajte za ogled in deljenje fotografij',
};
// A6 = 298 x 420 pt, A4 = 595 x 842 pt (pdfkit default unit).
const TEMPLATES = {
'table-card': { size: [298, 420], qrSize: 180, titleSize: 16, captionSize: 10, urlSize: 7 },
poster: { size: 'A4', qrSize: 360, titleSize: 28, captionSize: 16, urlSize: 10 },
};
// Bundled COMPLETE IBM Plex Sans (1019 glyphs: Latin + Cyrillic + Greek,
// verified via fontkit cmap) — pdfkit's built-in Helvetica is WinAnsi-only
// and silently drops e.g. Cyrillic event names, and the pre-existing
// assets/fonts/IBM-Plex-Sans/ files are 270-glyph Latin subsets with the
// same gap (codex review of #847). OFL license alongside the files.
const FONT_BOLD = path.join(__dirname, '../../../assets/fonts/IBM-Plex-Sans-Full/700.ttf');
const FONT_REGULAR = path.join(__dirname, '../../../assets/fonts/IBM-Plex-Sans-Full/400.ttf');
// A same-origin admin GET carries no Origin header, so the frontend passes
// window.location.origin explicitly. Only accept a plain http(s) origin.
const ORIGIN_RE = /^https?:\/\/[^\s/]+$/i;
// Configured FRONTEND_URL defaults to localhost on unconfigured installs —
// a QR pointing there is unusable on any other device (codex review of #847).
const LOCAL_BASE_RE = /^https?:\/\/(localhost|127\.|0\.0\.0\.0|\[::1\])/i;
async function loadShareUrl(eventId, requestOrigin) {
const event = await db('events').where({ id: eventId }).first();
if (!event) return { event: null, shareUrl: null };
// Single source of truth is the STORED share_link — exactly what the
// ShareLinkCard displays and the admin copies. Rebuilding from the
// current slug/token/short-URL setting can diverge for legacy absolute
// links or events created under a different short-URL setting, and a
// printed QR encoding a different URL than the card is a permanent
// mistake (codex review of #847, confirmation round). Only when no
// share_link is stored do we fall back to rebuilding it.
let link = event.share_link;
if (!link) {
const shareToken = getEventShareToken(event);
if (!shareToken) return { event, shareUrl: null };
({ shareLinkToStore: link } = await buildShareLinkVariants({ slug: event.slug, shareToken }));
}
const origin = typeof requestOrigin === 'string' && ORIGIN_RE.test(requestOrigin)
? requestOrigin.replace(/\/$/, '')
: null;
// Absolute + reachable → use as-is; loopback-absolute → re-anchor its
// path; relative → absolutize. Mirrors the frontend's buildShareLinkUrl.
if (/^https?:\/\//i.test(link) && !LOCAL_BASE_RE.test(link)) {
return { event, shareUrl: link };
}
let sharePath = link;
if (/^https?:\/\//i.test(link)) {
try { const u = new URL(link); sharePath = `${u.pathname}${u.search}`; } catch { /* keep as-is */ }
}
// Bare stored values (quote-/contract-converted events persist the raw
// token) resolve as /gallery/<token> — mirroring the frontend's
// buildShareLinkUrl exactly (codex review of #847, final round).
if (!sharePath.startsWith('/')) sharePath = `/gallery/${sharePath}`;
if (origin) return { event, shareUrl: `${origin}${sharePath}` };
const frontendBase = await getFrontendBaseUrl();
return { event, shareUrl: frontendBase ? `${frontendBase}${sharePath}` : sharePath };
}
module.exports = (router) => {
// QR image for the gallery share link.
router.get('/:id/qr', adminAuth, requirePermission('events.view'), requireEventOwnership, async (req, res) => {
try {
const { event, shareUrl } = await loadShareUrl(req.params.id, req.query.origin);
if (!event) return errorResponse(res, 'Event not found', 404);
if (!shareUrl) return errorResponse(res, 'Event has no share link', 409);
const format = req.query.format === 'svg' ? 'svg' : 'png';
const download = req.query.download === '1';
const disposition = `${download ? 'attachment' : 'inline'}; filename="qr-${event.slug}.${format}"`;
if (format === 'svg') {
const svg = await QRCode.toString(shareUrl, { type: 'svg', margin: 4 });
res.set('Content-Type', 'image/svg+xml');
res.set('Content-Disposition', disposition);
return res.send(svg);
}
const width = Math.min(Math.max(parseInt(req.query.size, 10) || 600, 128), 2048);
const png = await QRCode.toBuffer(shareUrl, { type: 'png', width, margin: 4 });
res.set('Content-Type', 'image/png');
res.set('Content-Disposition', disposition);
return res.send(png);
} catch (error) {
logger.error('Failed to generate gallery QR code:', error);
return errorResponse(res, error, 500, 'Failed to generate QR code');
}
});
// Print-ready PDF (table card / poster) with QR + event name + caption.
router.get('/:id/qr-print', adminAuth, requirePermission('events.view'), requireEventOwnership, async (req, res) => {
try {
const { event, shareUrl } = await loadShareUrl(req.params.id, req.query.origin);
if (!event) return errorResponse(res, 'Event not found', 404);
if (!shareUrl) return errorResponse(res, 'Event has no share link', 409);
const templateKey = TEMPLATES[req.query.template] ? req.query.template : 'table-card';
const tpl = TEMPLATES[templateKey];
const caption = PRINT_CAPTIONS[req.query.lang] || PRINT_CAPTIONS.en;
const qrPng = await QRCode.toBuffer(shareUrl, { type: 'png', width: tpl.qrSize * 3, margin: 4 });
const doc = new PDFDocument({
size: tpl.size,
margins: { top: 0, bottom: 0, left: 0, right: 0 },
info: { Title: `PicPeak QR — ${event.event_name}` },
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `attachment; filename="qr-${templateKey}-${event.slug}.pdf"`);
doc.pipe(res);
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const contentTop = pageHeight * 0.12;
// Fixed vertical layout: the title gets a bounded two-line region with
// ellipsis so an arbitrarily long event name can't push the QR/caption
// over the footer or off the page (codex review of #847). All positions
// below derive from constants, never from doc.y.
const titleBlockHeight = tpl.titleSize * 2.6;
doc.font(FONT_BOLD).fontSize(tpl.titleSize).fillColor('#1a1a1a')
.text(event.event_name, pageWidth * 0.1, contentTop, {
width: pageWidth * 0.8,
align: 'center',
height: titleBlockHeight,
ellipsis: true,
});
const qrX = (pageWidth - tpl.qrSize) / 2;
const qrY = contentTop + titleBlockHeight + tpl.titleSize * 0.5;
doc.image(qrPng, qrX, qrY, { width: tpl.qrSize, height: tpl.qrSize });
doc.font(FONT_REGULAR).fontSize(tpl.captionSize).fillColor('#333333')
.text(caption, pageWidth * 0.1, qrY + tpl.qrSize + tpl.captionSize, { width: pageWidth * 0.8, align: 'center' });
doc.font(FONT_REGULAR).fontSize(tpl.urlSize).fillColor('#888888')
.text(shareUrl, pageWidth * 0.05, pageHeight - pageHeight * 0.07, {
width: pageWidth * 0.9,
align: 'center',
height: pageHeight * 0.06,
ellipsis: true,
});
doc.end();
} catch (error) {
logger.error('Failed to generate QR print PDF:', error);
if (!res.headersSent) return errorResponse(res, error, 500, 'Failed to generate QR print PDF');
return res.end();
}
});
};
+4
View File
@@ -996,6 +996,9 @@
"archived": "Archiviert", "archived": "Archiviert",
"totalSize": "Gesamtgröße", "totalSize": "Gesamtgröße",
"shareLink": "Freigabelink", "shareLink": "Freigabelink",
"qrCode": "QR-Code",
"qrTableCard": "Tischkarte (A6)",
"qrPoster": "Poster (A4)",
"copyLink": "Link kopieren", "copyLink": "Link kopieren",
"linkCopied": "Link kopiert!", "linkCopied": "Link kopiert!",
"viewGallery": "Galerie ansehen", "viewGallery": "Galerie ansehen",
@@ -2928,6 +2931,7 @@
} }
}, },
"errors": { "errors": {
"downloadFailed": "Download fehlgeschlagen",
"galleryNotFound": "Galerie nicht gefunden", "galleryNotFound": "Galerie nicht gefunden",
"galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.", "galleryNotFoundMessage": "Diese Galerie existiert nicht oder wurde entfernt.",
"somethingWentWrong": "Etwas ist schiefgelaufen", "somethingWentWrong": "Etwas ist schiefgelaufen",
+4
View File
@@ -543,6 +543,9 @@
"archived": "Archived", "archived": "Archived",
"totalSize": "Total Size", "totalSize": "Total Size",
"shareLink": "Share Link", "shareLink": "Share Link",
"qrCode": "QR code",
"qrTableCard": "Table card (A6)",
"qrPoster": "Poster (A4)",
"copyLink": "Copy Link", "copyLink": "Copy Link",
"linkCopied": "Link copied!", "linkCopied": "Link copied!",
"viewGallery": "View Gallery", "viewGallery": "View Gallery",
@@ -2506,6 +2509,7 @@
} }
}, },
"errors": { "errors": {
"downloadFailed": "Download failed",
"galleryNotFound": "Gallery Not Found", "galleryNotFound": "Gallery Not Found",
"galleryNotFoundMessage": "This gallery does not exist or has been removed.", "galleryNotFoundMessage": "This gallery does not exist or has been removed.",
"somethingWentWrong": "Something went wrong", "somethingWentWrong": "Something went wrong",
+4
View File
@@ -393,6 +393,9 @@
"photoCount": "{{count}} fotos", "photoCount": "{{count}} fotos",
"totalSize": "Tamaño total", "totalSize": "Tamaño total",
"shareLink": "Enlace para compartir", "shareLink": "Enlace para compartir",
"qrCode": "Código QR",
"qrTableCard": "Tarjeta de mesa (A6)",
"qrPoster": "Póster (A4)",
"copyLink": "Copiar enlace", "copyLink": "Copiar enlace",
"linkCopied": "Enlace copiado!", "linkCopied": "Enlace copiado!",
"viewGallery": "Ver galería", "viewGallery": "Ver galería",
@@ -1608,6 +1611,7 @@
} }
}, },
"errors": { "errors": {
"downloadFailed": "Error al descargar",
"notFound": "No encontrado", "notFound": "No encontrado",
"galleryNotFound": "Galería no encontrada", "galleryNotFound": "Galería no encontrada",
"galleryNotFoundMessage": "Esta galería no existe o ha sido eliminada.", "galleryNotFoundMessage": "Esta galería no existe o ha sido eliminada.",
+4
View File
@@ -387,6 +387,9 @@
"archived": "Archivé", "archived": "Archivé",
"totalSize": "Taille totale", "totalSize": "Taille totale",
"shareLink": "Partager le lien", "shareLink": "Partager le lien",
"qrCode": "Code QR",
"qrTableCard": "Carte de table (A6)",
"qrPoster": "Affiche (A4)",
"copyLink": "Copier le lien", "copyLink": "Copier le lien",
"linkCopied": "Lien copié !", "linkCopied": "Lien copié !",
"viewGallery": "Voir la galerie", "viewGallery": "Voir la galerie",
@@ -1860,6 +1863,7 @@
} }
}, },
"errors": { "errors": {
"downloadFailed": "Échec du téléchargement",
"galleryNotFound": "Galerie introuvable", "galleryNotFound": "Galerie introuvable",
"galleryNotFoundMessage": "Cette galerie n'existe pas ou a été supprimée.", "galleryNotFoundMessage": "Cette galerie n'existe pas ou a été supprimée.",
"somethingWentWrong": "Quelque chose s'est mal passé", "somethingWentWrong": "Quelque chose s'est mal passé",
+4
View File
@@ -387,6 +387,9 @@
"archived": "Gearchiveerd", "archived": "Gearchiveerd",
"totalSize": "Totale grootte", "totalSize": "Totale grootte",
"shareLink": "Deellink", "shareLink": "Deellink",
"qrCode": "QR-code",
"qrTableCard": "Tafelkaart (A6)",
"qrPoster": "Poster (A4)",
"copyLink": "Link kopieren", "copyLink": "Link kopieren",
"linkCopied": "Link gekopieerd!", "linkCopied": "Link gekopieerd!",
"viewGallery": "Galerij bekijken", "viewGallery": "Galerij bekijken",
@@ -1849,6 +1852,7 @@
} }
}, },
"errors": { "errors": {
"downloadFailed": "Downloaden mislukt",
"galleryNotFound": "Galerij niet gevonden", "galleryNotFound": "Galerij niet gevonden",
"galleryNotFoundMessage": "Deze galerij bestaat niet of is verwijderd.", "galleryNotFoundMessage": "Deze galerij bestaat niet of is verwijderd.",
"somethingWentWrong": "Er is iets misgegaan", "somethingWentWrong": "Er is iets misgegaan",
+4
View File
@@ -395,6 +395,9 @@
"archived": "Arquivado", "archived": "Arquivado",
"totalSize": "Tamanho Total", "totalSize": "Tamanho Total",
"shareLink": "Link de Compartilhamento", "shareLink": "Link de Compartilhamento",
"qrCode": "Código QR",
"qrTableCard": "Cartão de mesa (A6)",
"qrPoster": "Pôster (A4)",
"copyLink": "Copiar Link", "copyLink": "Copiar Link",
"linkCopied": "Link copiado!", "linkCopied": "Link copiado!",
"viewGallery": "Ver Galeria", "viewGallery": "Ver Galeria",
@@ -1874,6 +1877,7 @@
} }
}, },
"errors": { "errors": {
"downloadFailed": "Falha no download",
"galleryNotFound": "Galeria Não Encontrada", "galleryNotFound": "Galeria Não Encontrada",
"galleryNotFoundMessage": "Esta galeria não existe ou foi removida.", "galleryNotFoundMessage": "Esta galeria não existe ou foi removida.",
"somethingWentWrong": "Algo deu errado", "somethingWentWrong": "Algo deu errado",
+4
View File
@@ -403,6 +403,9 @@
"archived": "В архиве", "archived": "В архиве",
"totalSize": "Общий размер", "totalSize": "Общий размер",
"shareLink": "Ссылка для доступа", "shareLink": "Ссылка для доступа",
"qrCode": "QR-код",
"qrTableCard": "Карточка на стол (A6)",
"qrPoster": "Постер (A4)",
"copyLink": "Копировать ссылку", "copyLink": "Копировать ссылку",
"linkCopied": "Ссылка скопирована!", "linkCopied": "Ссылка скопирована!",
"viewGallery": "Просмотреть галерею", "viewGallery": "Просмотреть галерею",
@@ -1899,6 +1902,7 @@
} }
}, },
"errors": { "errors": {
"downloadFailed": "Ошибка загрузки",
"galleryNotFound": "Галерея не найдена", "galleryNotFound": "Галерея не найдена",
"galleryNotFoundMessage": "Эта галерея не существует или была удалена.", "galleryNotFoundMessage": "Эта галерея не существует или была удалена.",
"somethingWentWrong": "Что-то пошло не так", "somethingWentWrong": "Что-то пошло не так",
+4
View File
@@ -387,6 +387,9 @@
"archived": "Arhiviran", "archived": "Arhiviran",
"totalSize": "Skupna velikost", "totalSize": "Skupna velikost",
"shareLink": "Povezava za deljenje", "shareLink": "Povezava za deljenje",
"qrCode": "QR-koda",
"qrTableCard": "Namizna kartica (A6)",
"qrPoster": "Plakat (A4)",
"copyLink": "Kopiraj povezavo", "copyLink": "Kopiraj povezavo",
"linkCopied": "Povezava kopirana!", "linkCopied": "Povezava kopirana!",
"viewGallery": "Ogled galerije", "viewGallery": "Ogled galerije",
@@ -1849,6 +1852,7 @@
} }
}, },
"errors": { "errors": {
"downloadFailed": "Prenos ni uspel",
"galleryNotFound": "Galerija ni najdena", "galleryNotFound": "Galerija ni najdena",
"galleryNotFoundMessage": "Ta galerija ne obstaja ali je bila odstranjena.", "galleryNotFoundMessage": "Ta galerija ne obstaja ali je bila odstranjena.",
"somethingWentWrong": "Nekaj je šlo narobe", "somethingWentWrong": "Nekaj je šlo narobe",
@@ -1,21 +1,74 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Copy, CheckCircle, Key, Mail } from 'lucide-react'; import { Copy, CheckCircle, Key, Mail, QrCode, Download } from 'lucide-react';
import type { Event } from '../../../types'; import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common'; import { Button, Card } from '../../../components/common';
import { eventsService } from '../../../services/events.service'; import { eventsService } from '../../../services/events.service';
import { buildShareLinkUrl } from '../../../utils/url'; import { buildShareLinkUrl } from '../../../utils/url';
import { isGalleryPublic } from '../../../utils/accessControl'; import { isGalleryPublic } from '../../../utils/accessControl';
const saveBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
interface ShareLinkCardProps { interface ShareLinkCardProps {
event: Event; event: Event;
setShowPasswordReset: (show: boolean) => void; setShowPasswordReset: (show: boolean) => void;
} }
export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset }) => { export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset }) => {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [copiedLink, setCopiedLink] = useState(false); const [copiedLink, setCopiedLink] = useState(false);
const [qrPreviewUrl, setQrPreviewUrl] = useState<string | null>(null);
// QR preview (#836) — fetched as a blob because the admin API needs the
// Bearer token; a plain <img src> would come back 401. The `stale` flag
// guards the async gap: without it, a response landing after unmount or
// an event switch would leak its object URL and could overwrite a newer
// event's preview with the previous gallery's QR (codex review of #847).
useEffect(() => {
if (!event.share_link) return;
let stale = false;
let objectUrl: string | null = null;
eventsService.getQrBlob(event.id, 'png', 300)
.then((blob) => {
const url = URL.createObjectURL(blob);
if (stale) {
URL.revokeObjectURL(url);
return;
}
objectUrl = url;
setQrPreviewUrl(url);
})
.catch(() => {
if (!stale) setQrPreviewUrl(null);
});
return () => {
stale = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [event.id, event.share_link]);
const handleQrDownload = async (kind: 'png' | 'svg' | 'table-card' | 'poster') => {
try {
if (kind === 'png' || kind === 'svg') {
saveBlob(await eventsService.getQrBlob(event.id, kind, 1024), `qr-${event.slug}.${kind}`);
} else {
const lang = (i18n.language || 'en').split('-')[0];
saveBlob(await eventsService.getQrPrintBlob(event.id, kind, lang), `qr-${kind}-${event.slug}.pdf`);
}
} catch {
toast.error(t('errors.downloadFailed', 'Download failed'));
}
};
const handleCopyLink = async () => { const handleCopyLink = async () => {
try { try {
@@ -83,6 +136,45 @@ export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPass
: t('events.shareWithGuests')} : t('events.shareWithGuests')}
</p> </p>
{event.share_link && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
<QrCode className="w-4 h-4" />
{t('events.qrCode', 'QR code')}
</h3>
{/* Stacks on phones; downloads stay available even when the preview
request failed the section keys off share-link availability,
not off a successfully loaded preview (codex review of #847). */}
<div className="flex flex-col sm:flex-row items-start gap-4">
{qrPreviewUrl ? (
<img
src={qrPreviewUrl}
alt={t('events.qrCode', 'QR code')}
className="w-28 h-28 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white p-1"
/>
) : (
<div className="w-28 h-28 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-700 flex items-center justify-center">
<QrCode className="w-8 h-8 text-neutral-300 dark:text-neutral-500" />
</div>
)}
<div className="flex-1 w-full grid grid-cols-1 sm:grid-cols-2 gap-2">
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={() => handleQrDownload('png')}>
PNG
</Button>
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={() => handleQrDownload('svg')}>
SVG
</Button>
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={() => handleQrDownload('table-card')}>
{t('events.qrTableCard', 'Table card (A6)')}
</Button>
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />} onClick={() => handleQrDownload('poster')}>
{t('events.qrPoster', 'Poster (A4)')}
</Button>
</div>
</div>
</div>
)}
{!event.is_archived && ( {!event.is_archived && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2"> <div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2">
<Button <Button
+21
View File
@@ -302,4 +302,25 @@ export const eventsService = {
const response = await api.post(`/admin/events/${eventId}/rename`, { newEventName, resendEmail }); const response = await api.post(`/admin/events/${eventId}/rename`, { newEventName, resendEmail });
return response.data; return response.data;
}, },
// Gallery QR code (#836). Admin API uses Bearer auth, so images are fetched
// as blobs — an <img src> would not carry the token. `origin` is passed so
// the backend can fall back to the admin browser's origin when the
// configured FRONTEND_URL is missing/localhost — the QR must encode the
// same URL the share-link card displays.
async getQrBlob(eventId: number, format: 'png' | 'svg', size?: number): Promise<Blob> {
const { data } = await api.get(`/admin/events/${eventId}/qr`, {
params: { format, size, origin: window.location.origin },
responseType: 'blob',
});
return data;
},
async getQrPrintBlob(eventId: number, template: 'table-card' | 'poster', lang: string): Promise<Blob> {
const { data } = await api.get(`/admin/events/${eventId}/qr-print`, {
params: { template, lang, origin: window.location.origin },
responseType: 'blob',
});
return data;
},
}; };