diff --git a/admin/components/_getSettings.php b/admin/components/_getSettings.php
index c087c89d6..2dd2f81d0 100644
--- a/admin/components/_getSettings.php
+++ b/admin/components/_getSettings.php
@@ -48,6 +48,9 @@
case 'icon':
echo AdminInput::renderIcon($setting, $i18ntag);
break;
+ case 'lucide-icon':
+ echo AdminInput::renderLucideIcon($setting, $i18ntag);
+ break;
case 'input':
case 'number':
echo AdminInput::renderInput($setting, $i18ntag);
diff --git a/admin/components/footer.scripts.php b/admin/components/footer.scripts.php
index 9ede201fd..23a99a22e 100644
--- a/admin/components/footer.scripts.php
+++ b/admin/components/footer.scripts.php
@@ -5,4 +5,7 @@
$assetService = AssetService::getInstance();
echo '';
+echo '';
+echo '';
echo '';
+echo '';
diff --git a/api/admin.php b/api/admin.php
index 798eba21a..af2036387 100644
--- a/api/admin.php
+++ b/api/admin.php
@@ -11,12 +11,15 @@
use Photobooth\Service\ConfigurationService;
use Photobooth\Service\DatabaseManagerService;
use Photobooth\Service\ImageMetadataCacheService;
+use Photobooth\Service\LanguageService;
use Photobooth\Service\LoggerService;
use Photobooth\Service\MailService;
use Photobooth\Service\PrintManagerService;
use Photobooth\Service\ProcessService;
use Photobooth\Utility\ArrayUtility;
use Photobooth\Utility\AdminKeypad;
+use Photobooth\Utility\EventIconCatalogUtility;
+use Photobooth\Utility\EventSymbolUtility;
use Photobooth\Utility\PathUtility;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
@@ -25,6 +28,7 @@
$loggerService = LoggerService::getInstance();
$logger = $loggerService->getLogger('main');
$logger->debug(basename($_SERVER['PHP_SELF']));
+$languageService = LanguageService::getInstance();
checkCsrfOrFail($_POST);
@@ -35,7 +39,190 @@
$action = $data['type'] ?? null;
// Reset
-if ($action === 'reset') {
+if ($action === 'event_symbol_upload') {
+ if (!isset($_FILES['event_symbol_image']) || !is_array($_FILES['event_symbol_image'])) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_no_file_uploaded'),
+ ]);
+ exit();
+ }
+
+ $upload = $_FILES['event_symbol_image'];
+ $uploadError = isset($upload['error']) ? (int) $upload['error'] : UPLOAD_ERR_NO_FILE;
+ if ($uploadError !== UPLOAD_ERR_OK) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => str_replace(
+ '%d',
+ (string) $uploadError,
+ $languageService->translate('event_symbol:api_upload_failed_code'),
+ ),
+ ]);
+ exit();
+ }
+
+ $tmpName = (string) ($upload['tmp_name'] ?? '');
+ if ($tmpName === '' || !is_uploaded_file($tmpName)) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_invalid_upload_file'),
+ ]);
+ exit();
+ }
+
+ $originalName = (string) ($upload['name'] ?? '');
+ $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
+ if (!EventIconCatalogUtility::isAllowedCustomImageExtension($extension)) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_file_type_not_allowed'),
+ ]);
+ exit();
+ }
+
+ $maxBytes = 8 * 1024 * 1024;
+ $sizeBytes = isset($upload['size']) ? (int) $upload['size'] : 0;
+ if ($sizeBytes <= 0 || $sizeBytes > $maxBytes) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_file_empty_or_too_large'),
+ ]);
+ exit();
+ }
+
+ $allowedMimes = [
+ 'svg' => ['image/svg+xml', 'text/xml', 'application/xml', 'text/plain'],
+ 'png' => ['image/png'],
+ 'jpg' => ['image/jpeg'],
+ 'jpeg' => ['image/jpeg'],
+ 'webp' => ['image/webp'],
+ 'gif' => ['image/gif'],
+ 'avif' => ['image/avif'],
+ ];
+ $mime = '';
+ if (function_exists('finfo_open')) {
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ if ($finfo !== false) {
+ $mime = (string) finfo_file($finfo, $tmpName);
+ finfo_close($finfo);
+ }
+ }
+ if ($mime === '' || !isset($allowedMimes[$extension]) || !in_array($mime, $allowedMimes[$extension], true)) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_invalid_mime_type'),
+ ]);
+ exit();
+ }
+
+ $targetDirRelative = EventIconCatalogUtility::getCustomImageDirectoryRelative();
+ $targetDirAbsolute = EventIconCatalogUtility::getCustomImageDirectoryAbsolute();
+ if (!is_dir($targetDirAbsolute) && !@mkdir($targetDirAbsolute, 0775, true) && !is_dir($targetDirAbsolute)) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_upload_dir_create_failed'),
+ ]);
+ exit();
+ }
+ if (!is_writable($targetDirAbsolute)) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_upload_dir_not_writable'),
+ ]);
+ exit();
+ }
+
+ $basename = strtolower(pathinfo($originalName, PATHINFO_FILENAME));
+ $basename = preg_replace('/[^a-z0-9._-]+/', '-', $basename);
+ $basename = trim((string) $basename, '-._');
+ if ($basename === '') {
+ $basename = 'symbol';
+ }
+
+ $fileName = $basename . '.' . $extension;
+ $counter = 1;
+ while (is_file($targetDirAbsolute . DIRECTORY_SEPARATOR . $fileName) && $counter < 1000) {
+ $fileName = $basename . '-' . $counter . '.' . $extension;
+ $counter++;
+ }
+
+ $targetAbsolutePath = $targetDirAbsolute . DIRECTORY_SEPARATOR . $fileName;
+ if (!move_uploaded_file($tmpName, $targetAbsolutePath)) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_file_save_failed'),
+ ]);
+ exit();
+ }
+ @chmod($targetAbsolutePath, 0644);
+
+ $relativePath = $targetDirRelative . '/' . $fileName;
+ $iconEntry = EventIconCatalogUtility::buildCustomImageEntry($relativePath);
+ if ($iconEntry === null) {
+ @unlink($targetAbsolutePath);
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_custom_icon_create_failed'),
+ ]);
+ exit();
+ }
+
+ EventIconCatalogUtility::invalidateCache();
+
+ echo json_encode([
+ 'status' => 'success',
+ 'message' => $languageService->translate('event_symbol:api_upload_success'),
+ 'icon' => $iconEntry,
+ ]);
+ exit();
+} elseif ($action === 'event_symbol_delete') {
+ $value = (string) ($data['value'] ?? '');
+ $normalized = EventSymbolUtility::normalize($value);
+ if (!EventSymbolUtility::isCustomImageSymbol($normalized)) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_invalid_custom_image_selection'),
+ ]);
+ exit();
+ }
+
+ $relativePath = EventSymbolUtility::getCustomImagePath($normalized);
+ $relativePath = ltrim(PathUtility::fixFilePath($relativePath), '/');
+ $allowedPrefix = rtrim(EventIconCatalogUtility::getCustomImageDirectoryRelative(), '/') . '/';
+ if (!str_starts_with($relativePath, $allowedPrefix) || str_contains($relativePath, '..')) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_invalid_file_path'),
+ ]);
+ exit();
+ }
+
+ $absolutePath = PathUtility::getAbsolutePath($relativePath);
+ if (!is_file($absolutePath)) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_file_not_found'),
+ ]);
+ exit();
+ }
+
+ if (!@unlink($absolutePath)) {
+ echo json_encode([
+ 'status' => 'error',
+ 'message' => $languageService->translate('event_symbol:api_file_delete_failed'),
+ ]);
+ exit();
+ }
+
+ EventIconCatalogUtility::invalidateCache();
+
+ echo json_encode([
+ 'status' => 'success',
+ 'message' => $languageService->translate('event_symbol:api_delete_success'),
+ ]);
+ exit();
+} elseif ($action === 'reset') {
// First step in resetting the photobooth is always resetting the logs
// This ensures we are able to write logmessages afterwards.
$loggerService->addLogger('main');
@@ -124,6 +311,14 @@
} elseif ($action === 'config') {
$logger->debug('Saving Photobooth configuration...');
$newConfig = ArrayUtility::mergeRecursive($defaultConfig, $data);
+ if (isset($newConfig['event']['symbol'])) {
+ $newConfig['event']['symbol'] = EventSymbolUtility::normalize($newConfig['event']['symbol']);
+ }
+ $collageInput = $data['collage'] ?? null;
+ if (!is_array($collageInput) || !array_key_exists('background', $collageInput)) {
+ // Keep legacy single background value if the field is not part of the current admin form.
+ $newConfig['collage']['background'] = $config['collage']['background'] ?? '';
+ }
$rootPath = PathUtility::getRootPath();
diff --git a/api/settings.php b/api/settings.php
index 35e7f5afb..a612eb5cd 100644
--- a/api/settings.php
+++ b/api/settings.php
@@ -1,6 +1,7 @@
{
+ const t = token.trim();
+ if (!t || (!t.startsWith('fa-') && t !== 'fa')) {
+ return;
+ }
+ if (unique[t]) {
+ return;
+ }
+ unique[t] = true;
+ classes.push(t);
+ if (t.startsWith('fa-') && styleClasses.indexOf(t) === -1) {
+ hasIconClass = true;
+ }
+ });
+
+ if (!hasIconClass) {
+ return '';
+ }
+
+ if (!unique.fa) {
+ classes.unshift('fa');
+ }
+
+ return classes.join(' ');
+}
+
+function isIconifyValue(value) {
+ const raw = String(value || '')
+ .trim()
+ .toLowerCase();
+ if (!raw) {
+ return false;
+ }
+
+ const cleaned = raw.startsWith('iconify:') ? raw.slice(8) : raw;
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9][a-z0-9._-]*$/.test(cleaned);
+}
+
+function normalizeIconifyValue(value) {
+ let raw = String(value || '')
+ .trim()
+ .toLowerCase();
+ if (!raw) {
+ return '';
+ }
+
+ raw = raw.startsWith('iconify:') ? raw.slice(8) : raw;
+ const parts = raw.split(':');
+ if (parts.length !== 2) {
+ return '';
+ }
+
+ const prefix = parts[0]
+ .replace(/[^a-z0-9-]+/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ const icon = parts[1]
+ .replace(/[^a-z0-9._-]+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^[-._]+|[-._]+$/g, '');
+
+ if (!prefix || !icon) {
+ return '';
+ }
+
+ return `${prefix}:${icon}`;
+}
+
+function looksLikeCustomImagePath(value) {
+ const normalized = String(value || '')
+ .trim()
+ .replace(/\\/g, '/')
+ .replace(/^\/+/, '')
+ .toLowerCase();
+ return normalized.indexOf(CUSTOM_IMAGE_DIRECTORY) === 0;
+}
+
+function normalizeCustomImageValue(value) {
+ let raw = String(value || '').trim();
+ if (!raw) {
+ return '';
+ }
+
+ if (raw.toLowerCase().startsWith(CUSTOM_IMAGE_PREFIX)) {
+ raw = raw.slice(CUSTOM_IMAGE_PREFIX.length);
+ }
+
+ raw = raw
+ .replace(/\\/g, '/')
+ .replace(/^\/+/, '')
+ .replace(/\/{2,}/g, '/');
+
+ if (!raw || raw.indexOf('..') !== -1 || raw.indexOf('\0') !== -1) {
+ return '';
+ }
+
+ if (!/^[A-Za-z0-9._/-]+$/.test(raw)) {
+ return '';
+ }
+
+ if (raw.toLowerCase().indexOf(CUSTOM_IMAGE_DIRECTORY) !== 0) {
+ return '';
+ }
+
+ if (!/\.(svg|png|jpe?g|webp|gif|avif)$/i.test(raw)) {
+ return '';
+ }
+
+ return `${CUSTOM_IMAGE_PREFIX}${raw}`;
+}
+
+function isCustomImageValue(value) {
+ return normalizeCustomImageValue(value) !== '';
+}
+
+function buildPublicPath(relativePath) {
+ const normalized = String(relativePath || '').replace(/^\/+/, '');
+ if (!normalized) {
+ return '';
+ }
+
+ const base =
+ typeof environment !== 'undefined' && environment && typeof environment.baseUrl === 'string'
+ ? environment.baseUrl
+ : '/';
+ const basePath = base.endsWith('/') ? base : `${base}/`;
+ return new URL(normalized, `${window.location.origin}${basePath}`).toString();
+}
+
+function normalizeEventSymbolValue(value) {
+ let raw = String(value || '').trim();
+ if (!raw) {
+ return 'camera';
+ }
+
+ if (raw.toLowerCase().startsWith(CUSTOM_IMAGE_PREFIX) || looksLikeCustomImagePath(raw)) {
+ const customImage = normalizeCustomImageValue(raw);
+ return customImage || 'camera';
+ }
+
+ const lower = raw.toLowerCase();
+ if (lower.indexOf('lucide:') === 0) {
+ raw = raw.slice(7);
+ } else if (lower.indexOf('fa:') === 0) {
+ raw = raw.slice(3);
+ } else if (lower.indexOf('iconify:') === 0) {
+ raw = raw.slice(8);
+ }
+
+ if (isFontAwesomeValue(raw)) {
+ return sanitizeFontAwesomeClasses(raw) || 'fa fa-camera';
+ }
+
+ if (isIconifyValue(raw)) {
+ const iconifyName = normalizeIconifyValue(raw);
+ return iconifyName ? `iconify:${iconifyName}` : 'iconify:mdi:camera';
+ }
+
+ return normalizeLucideName(raw) || 'camera';
+}
+
+function mapLegacyToLucide(value) {
+ const normalized = String(value || '')
+ .trim()
+ .toLowerCase();
+ if (LEGACY_TO_LUCIDE_MAP[normalized]) {
+ return LEGACY_TO_LUCIDE_MAP[normalized];
+ }
+
+ const tokens = normalized.split(/\s+/);
+ for (let i = 0; i < tokens.length; i++) {
+ if (LEGACY_TO_LUCIDE_MAP[tokens[i]]) {
+ return LEGACY_TO_LUCIDE_MAP[tokens[i]];
+ }
+ }
+
+ return 'camera';
+}
+
+function humanizeIconName(iconName) {
+ return String(iconName || '')
+ .replace(/[._]/g, '-')
+ .split('-')
+ .filter((part) => part)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+}
+
+function getFileBasename(pathValue) {
+ const normalized = String(pathValue || '')
+ .replace(/\\/g, '/')
+ .split('/');
+ const fileName = normalized.length ? normalized[normalized.length - 1] : '';
+ return fileName.replace(/\.[^.]+$/, '');
+}
+
+function getCatalogFromPicker(picker) {
+ const script = picker.querySelector('.adminIconSelection-catalog');
+ if (!script || !script.textContent) {
+ return null;
+ }
+
+ try {
+ const parsed = JSON.parse(script.textContent);
+ if (!parsed || typeof parsed !== 'object') {
+ return null;
+ }
+
+ return {
+ categories: Array.isArray(parsed.categories) ? parsed.categories : [],
+ icons: Array.isArray(parsed.icons) ? parsed.icons : []
+ };
+ } catch {
+ return null;
+ }
+}
+
+function getLegacyIconsFromPicker(picker) {
+ const script = picker.querySelector('.adminIconSelection-legacy-icons');
+ if (script && script.textContent) {
+ try {
+ const parsed = JSON.parse(script.textContent);
+ if (Array.isArray(parsed)) {
+ return parsed;
+ }
+ } catch {
+ // fallback below
+ }
+ }
+
+ return [
+ { value: 'fa-camera', label: 'Camera' },
+ { value: 'fa-camera-retro', label: 'Camera Retro' },
+ { value: 'fa-birthday-cake', label: 'Birthday Cake' },
+ { value: 'fa-gift', label: 'Gift' },
+ { value: 'fa-tree', label: 'Tree' },
+ { value: 'fa-snowflake', label: 'Snowflake' },
+ { value: 'fa-solid fa-sun', label: 'Sun' },
+ { value: 'fa-solid fa-heart', label: 'Heart (Filled)' },
+ { value: 'fa-regular fa-heart', label: 'Heart' },
+ { value: 'fa-anchor', label: 'Anchor' },
+ { value: 'fa-users', label: 'People' }
+ ];
+}
+
+function fallbackCatalog() {
+ return {
+ categories: [
+ { id: 'all', title: getCategoryTitle('all', 'All'), source: 'system' },
+ { id: 'event', title: getCategoryTitle('event', 'Event/Party'), source: 'mixed' },
+ { id: 'photo', title: getCategoryTitle('photo', 'Photo/Media'), source: 'mixed' },
+ { id: 'legacy', title: getCategoryTitle('legacy', 'Classic (FA)'), source: 'legacy' },
+ {
+ id: CUSTOM_IMAGE_CATEGORY,
+ title: getCategoryTitle(CUSTOM_IMAGE_CATEGORY, 'Custom images'),
+ source: 'custom'
+ }
+ ],
+ icons: [
+ {
+ provider: 'lucide',
+ value: 'camera',
+ label: 'Camera',
+ categories: ['photo', 'event'],
+ search: 'camera photo event'
+ },
+ {
+ provider: 'lucide',
+ value: 'heart',
+ label: 'Heart',
+ categories: ['love', 'event'],
+ search: 'heart love event'
+ },
+ {
+ provider: 'lucide',
+ value: 'cake',
+ label: 'Cake',
+ categories: ['food', 'event'],
+ search: 'cake party event'
+ }
+ ]
+ };
+}
+
+function normalizeCategories(rawCategories) {
+ if (!Array.isArray(rawCategories)) {
+ return ['all'];
+ }
+
+ const unique = {};
+ const categories = [];
+ rawCategories.forEach((category) => {
+ const id = String(category || '').trim();
+ if (!id || unique[id]) {
+ return;
+ }
+ unique[id] = true;
+ categories.push(id);
+ });
+
+ if (!unique.all) {
+ categories.push('all');
+ }
+
+ return categories;
+}
+
+function createEntryFromCatalogItem(item) {
+ if (!item || typeof item !== 'object') {
+ return null;
+ }
+
+ const rawValue = String(item.value || '').trim();
+ const normalizedValue = normalizeEventSymbolValue(rawValue);
+ if (!normalizedValue) {
+ return null;
+ }
+
+ let provider = String(item.provider || '')
+ .trim()
+ .toLowerCase();
+ if (!provider) {
+ if (isCustomImageValue(normalizedValue)) {
+ provider = 'image';
+ } else if (isFontAwesomeValue(normalizedValue)) {
+ provider = 'fa';
+ } else if (isIconifyValue(normalizedValue)) {
+ provider = 'iconify';
+ } else {
+ provider = 'lucide';
+ }
+ }
+
+ const label = String(item.label || humanizeIconName(rawValue || normalizedValue));
+ const categories = normalizeCategories(item.categories || []);
+
+ const entry = {
+ provider: provider,
+ value: normalizedValue,
+ label: label,
+ categories: categories,
+ searchText: `${normalizedValue} ${label.toLowerCase()} ${String(item.search || '').toLowerCase()}`,
+ lucideName: '',
+ iconifyName: '',
+ faClasses: '',
+ imagePath: '',
+ imageSrc: ''
+ };
+
+ if (provider === 'fa') {
+ entry.faClasses = sanitizeFontAwesomeClasses(normalizedValue);
+ if (!entry.faClasses) {
+ return null;
+ }
+ entry.lucideName = mapLegacyToLucide(normalizedValue);
+ } else if (provider === 'iconify') {
+ const iconifyName = normalizeIconifyValue(normalizedValue);
+ if (!iconifyName) {
+ return null;
+ }
+ entry.iconifyName = iconifyName;
+ } else if (provider === 'image') {
+ const imageValue = normalizeCustomImageValue(normalizedValue);
+ if (!imageValue) {
+ return null;
+ }
+ entry.value = imageValue;
+ entry.imagePath = imageValue.slice(CUSTOM_IMAGE_PREFIX.length);
+ entry.imageSrc = buildPublicPath(entry.imagePath);
+ if (!entry.imageSrc) {
+ return null;
+ }
+ if (!entry.label || entry.label === entry.value) {
+ entry.label = humanizeIconName(getFileBasename(entry.imagePath));
+ }
+ entry.categories = normalizeCategories([CUSTOM_IMAGE_CATEGORY]);
+ } else {
+ const lucideName = normalizeLucideName(normalizedValue);
+ if (!lucideName) {
+ return null;
+ }
+ entry.lucideName = lucideName;
+ }
+
+ return entry;
+}
+
+function createEntryFromRawValue(value) {
+ const normalizedValue = normalizeEventSymbolValue(value);
+ if (!normalizedValue) {
+ return null;
+ }
+
+ let provider = 'lucide';
+ if (isCustomImageValue(normalizedValue)) {
+ provider = 'image';
+ } else if (isFontAwesomeValue(normalizedValue)) {
+ provider = 'fa';
+ } else if (isIconifyValue(normalizedValue)) {
+ provider = 'iconify';
+ }
+
+ const rawLabel =
+ provider === 'image' ? humanizeIconName(getFileBasename(normalizedValue)) : humanizeIconName(normalizedValue);
+
+ return createEntryFromCatalogItem({
+ provider: provider,
+ value: normalizedValue,
+ label: rawLabel,
+ categories: provider === 'image' ? [CUSTOM_IMAGE_CATEGORY] : ['event'],
+ search: normalizedValue
+ });
+}
+
+function buildPickerEntries(picker) {
+ const entries = [];
+ const seenValues = {};
+
+ const catalog = getCatalogFromPicker(picker) || fallbackCatalog();
+ catalog.icons.forEach((item) => {
+ const entry = createEntryFromCatalogItem(item);
+ if (!entry || seenValues[entry.value]) {
+ return;
+ }
+
+ seenValues[entry.value] = true;
+ entries.push(entry);
+ });
+
+ const legacyIcons = getLegacyIconsFromPicker(picker);
+ legacyIcons.forEach((item) => {
+ const normalizedValue = normalizeEventSymbolValue(item && item.value ? item.value : '');
+ const faClasses = sanitizeFontAwesomeClasses(normalizedValue);
+ if (!faClasses || seenValues[normalizedValue]) {
+ return;
+ }
+
+ seenValues[normalizedValue] = true;
+ const label = item && item.label ? String(item.label) : normalizedValue;
+ const lucideFallback = mapLegacyToLucide(normalizedValue);
+
+ entries.push({
+ provider: 'fa',
+ value: normalizedValue,
+ label: label,
+ categories: ['legacy', 'all'],
+ searchText: `${normalizedValue} ${label.toLowerCase()} ${lucideFallback} legacy`,
+ lucideName: lucideFallback,
+ iconifyName: '',
+ faClasses: faClasses,
+ imagePath: '',
+ imageSrc: ''
+ });
+ });
+
+ return entries;
+}
+
+function buildCategoryList(picker, entries) {
+ const catalog = getCatalogFromPicker(picker) || fallbackCatalog();
+ const available = {};
+ entries.forEach((entry) => {
+ entry.categories.forEach((id) => {
+ available[id] = true;
+ });
+ });
+
+ const result = [];
+ const seen = {};
+
+ catalog.categories.forEach((item) => {
+ if (!item || typeof item !== 'object') {
+ return;
+ }
+
+ const id = String(item.id || '').trim();
+ const title = getCategoryTitle(id, String(item.title || '').trim());
+ if (!id || !title || seen[id] || !available[id]) {
+ return;
+ }
+
+ seen[id] = true;
+ result.push({ id: id, title: title, source: String(item.source || 'system') });
+ });
+
+ if (!seen.all) {
+ result.unshift({ id: 'all', title: getCategoryTitle('all', 'All'), source: 'system' });
+ }
+
+ return result;
+}
+
+function renderLucideIcons() {
+ if (!window.lucide || !window.lucide.createIcons || !window.lucide.icons) {
+ return;
+ }
+
+ window.lucide.createIcons({
+ icons: window.lucide.icons
+ });
+
+ document
+ .querySelectorAll('.adminIconSelection svg.lucide, .event-symbol-icon.lucide, .screensaver-event-icon.lucide')
+ .forEach((svg) => {
+ svg.setAttribute('stroke-width', '2.8');
+ });
+}
+
+function escapeAttribute(value) {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
+
+function renderEntryIcon(entry, sizeClass) {
+ const wrapClass = `adminIconSelection-iconWrap ${sizeClass}`;
+
+ if (entry.provider === 'fa') {
+ return ``;
+ }
+
+ if (entry.provider === 'iconify') {
+ return ``;
+ }
+
+ if (entry.provider === 'image') {
+ return `
`;
+ }
+
+ return ``;
+}
+
+function findEntryByValue(picker, value) {
+ const state = picker._iconPickerState;
+ if (!state) {
+ return null;
+ }
+
+ const normalized = normalizeEventSymbolValue(value);
+ for (let i = 0; i < state.entries.length; i++) {
+ if (state.entries[i].value === normalized) {
+ return state.entries[i];
+ }
+ }
+
+ return null;
+}
+
+function updateCategoryButtons(picker, activeCategory) {
+ picker.querySelectorAll('.adminIconSelection-theme').forEach((button) => {
+ const isActive = button.getAttribute('data-icon-category') === activeCategory;
+ button.classList.toggle('bg-brand-1', isActive);
+ button.classList.toggle('text-white', isActive);
+ button.classList.toggle('border-brand-1', isActive);
+ button.classList.toggle('bg-white', !isActive);
+ button.classList.toggle('text-gray-700', !isActive);
+ });
+}
+
+function updateDeleteCustomImageButton(picker, value) {
+ const button = picker.querySelector('.adminIconSelection-deleteImage');
+ if (!button) {
+ return;
+ }
+
+ const normalized = normalizeEventSymbolValue(value);
+ const isCustom = isCustomImageValue(normalized);
+
+ button.classList.toggle('hidden', !isCustom);
+ if (isCustom) {
+ button.setAttribute('data-icon-value', normalized);
+ } else {
+ button.removeAttribute('data-icon-value');
+ }
+}
+
+function updatePickerPreview(picker, value) {
+ const entry = findEntryByValue(picker, value) || createEntryFromRawValue(value);
+ const previewButton = picker.querySelector('.adminIconSelection-open');
+ const currentText = picker.querySelector('.adminIconSelection-current');
+
+ if (!previewButton || !entry) {
+ return;
+ }
+
+ previewButton.innerHTML = renderEntryIcon(entry, 'adminIconSelection-iconWrap--preview text-brand-1');
+ if (currentText) {
+ currentText.textContent = normalizeEventSymbolValue(entry.value);
+ }
+
+ const directInput = picker.querySelector('.adminIconSelection-directInput');
+ if (directInput) {
+ directInput.value = normalizeEventSymbolValue(entry.value);
+ }
+
+ updateDeleteCustomImageButton(picker, entry.value);
+ renderLucideIcons();
+}
+
+function setPickerIcon(picker, value) {
+ const input = picker.querySelector('.adminIconSelection-input');
+ if (!input) {
+ return;
+ }
+
+ const normalized = normalizeEventSymbolValue(value);
+ input.value = normalized;
+ updatePickerPreview(picker, normalized);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+}
+
+function buildIconButton(picker, entry) {
+ const selectedValue = normalizeEventSymbolValue(
+ picker.querySelector('.adminIconSelection-input')?.value || picker.getAttribute('data-default-icon') || 'camera'
+ );
+
+ const isSelected = selectedValue === entry.value;
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className =
+ 'adminIconSelection-item min-h-20 rounded-lg border p-2 flex flex-col items-center justify-center gap-1 transition text-[11px] ' +
+ (entry.provider === 'image' ? 'min-h-24 ' : '') +
+ (isSelected
+ ? 'border-brand-1 bg-brand-1/10 text-brand-1'
+ : 'border-gray-300 bg-white text-gray-700 hover:border-brand-1 hover:text-brand-1');
+ button.setAttribute('data-icon-name', entry.value);
+ button.setAttribute('title', `${entry.label} (${entry.value})`);
+ button.innerHTML =
+ renderEntryIcon(entry, 'adminIconSelection-iconWrap--grid') +
+ '' +
+ entry.label +
+ '';
+
+ if (entry.provider === 'image') {
+ const deleteButton = document.createElement('button');
+ deleteButton.type = 'button';
+ deleteButton.className = 'adminIconSelection-itemDelete';
+ deleteButton.textContent = translateLabel('event_symbol:delete_image', 'Delete image');
+ deleteButton.addEventListener('click', function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ deleteCustomImage(picker, entry.value);
+ });
+ button.appendChild(deleteButton);
+ }
+
+ button.addEventListener('click', function () {
+ setPickerIcon(picker, entry.value);
+ closeAdminIconSelect();
+ });
+
+ return button;
+}
+
+function renderIconGridForPicker(picker) {
+ const grid = picker.querySelector('.adminIconSelection-grid');
+ if (!grid || !picker._iconPickerState) {
+ return;
+ }
+
+ const state = picker._iconPickerState;
+ const query = String(state.searchQuery || '')
+ .trim()
+ .toLowerCase();
+ const activeCategory = state.activeCategory || 'all';
+
+ const filtered = state.entries.filter((entry) => {
+ if (activeCategory !== 'all' && entry.categories.indexOf(activeCategory) === -1) {
+ return false;
+ }
+
+ if (!query) {
+ return true;
+ }
+
+ return entry.searchText.indexOf(query) !== -1;
+ });
+
+ grid.innerHTML = '';
+ if (filtered.length === 0) {
+ const noIconsLabel = translateLabel('event_symbol:no_icons_found', 'No icons found for this filter.');
+ grid.innerHTML = `
${escapeAttribute(noIconsLabel)}
`;
+ return;
+ }
+
+ const visible = filtered.slice(0, MAX_ICON_GRID_ITEMS);
+ visible.forEach((entry) => {
+ grid.appendChild(buildIconButton(picker, entry));
+ });
+
+ if (filtered.length > MAX_ICON_GRID_ITEMS) {
+ const hint = document.createElement('div');
+ hint.className = 'col-span-full text-center text-xs text-gray-500 py-2';
+ const hiddenCount = filtered.length - MAX_ICON_GRID_ITEMS;
+ const hiddenTemplate = translateLabel(
+ 'event_symbol:hidden_icons_hint',
+ '%d more icons hidden. Please narrow down search or category.'
+ );
+ hint.textContent = hiddenTemplate.replace('%d', String(hiddenCount));
+ grid.appendChild(hint);
+ }
+
+ renderLucideIcons();
+}
+
+function renderCategoryButtons(picker) {
+ const state = picker._iconPickerState;
+ const themesContainer = picker.querySelector('.adminIconSelection-themes');
+ if (!themesContainer || !state) {
+ return;
+ }
+
+ themesContainer.innerHTML = '';
+ state.categories.forEach((category) => {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.setAttribute('data-icon-category', category.id);
+ button.className =
+ 'adminIconSelection-theme px-2 py-1 rounded-full text-xs border border-gray-300 bg-white text-gray-700 hover:border-brand-1 hover:text-brand-1 transition';
+ button.textContent = category.title;
+ button.addEventListener('click', function () {
+ picker._iconPickerState.activeCategory = category.id;
+ updateCategoryButtons(picker, category.id);
+ renderIconGridForPicker(picker);
+ });
+ themesContainer.appendChild(button);
+ });
+
+ updateCategoryButtons(picker, state.activeCategory || 'all');
+}
+
+function refreshPickerCategories(picker) {
+ if (!picker._iconPickerState) {
+ return;
+ }
+
+ const state = picker._iconPickerState;
+ const categories = buildCategoryList(picker, state.entries);
+ state.categories = categories;
+
+ if (!categories.some((item) => item.id === state.activeCategory)) {
+ state.activeCategory = categories.some((item) => item.id === 'all') ? 'all' : categories[0]?.id || 'all';
+ }
+
+ renderCategoryButtons(picker);
+}
+
+function updateUploadFileName(picker) {
+ const fileInput = picker.querySelector('.adminIconSelection-uploadInput');
+ const fileNameElement = picker.querySelector('.adminIconSelection-uploadFileName');
+ if (!fileInput || !fileNameElement) {
+ return;
+ }
+
+ const emptyLabel =
+ fileNameElement.getAttribute('data-empty-label') ||
+ translateLabel('event_symbol:upload_no_file_selected', 'No file selected');
+ const fileName =
+ fileInput.files && fileInput.files.length > 0 && fileInput.files[0] && fileInput.files[0].name
+ ? fileInput.files[0].name
+ : emptyLabel;
+
+ fileNameElement.textContent = fileName;
+ fileNameElement.setAttribute('title', fileName);
+ fileNameElement.classList.toggle('is-empty', fileName === emptyLabel);
+}
+
+function setUploadStatus(picker, message, type) {
+ const status = picker.querySelector('.adminIconSelection-uploadStatus');
+ if (!status) {
+ return;
+ }
+
+ status.textContent = message || '';
+ status.classList.remove('text-gray-600', 'text-rose-600', 'text-emerald-600');
+
+ if (type === 'error') {
+ status.classList.add('text-rose-600');
+ } else if (type === 'success') {
+ status.classList.add('text-emerald-600');
+ } else {
+ status.classList.add('text-gray-600');
+ }
+}
+
+function upsertEntry(picker, entry) {
+ if (!picker._iconPickerState || !entry) {
+ return;
+ }
+
+ const state = picker._iconPickerState;
+ const normalizedValue = normalizeEventSymbolValue(entry.value);
+ const existingIndex = state.entries.findIndex((item) => item.value === normalizedValue);
+
+ if (existingIndex >= 0) {
+ state.entries[existingIndex] = entry;
+ } else {
+ state.entries.push(entry);
+ }
+
+ refreshPickerCategories(picker);
+ renderIconGridForPicker(picker);
+}
+
+function removeEntryByValue(picker, value) {
+ if (!picker._iconPickerState) {
+ return;
+ }
+
+ const normalizedValue = normalizeEventSymbolValue(value);
+ const state = picker._iconPickerState;
+ state.entries = state.entries.filter((entry) => entry.value !== normalizedValue);
+
+ refreshPickerCategories(picker);
+ renderIconGridForPicker(picker);
+}
+
+async function postIconAction(formData) {
+ if (typeof csrf !== 'undefined' && csrf && csrf.key && csrf.token) {
+ formData.append(csrf.key, csrf.token);
+ }
+
+ const response = await fetch(ADMIN_API_URL, {
+ method: 'POST',
+ body: formData,
+ credentials: 'same-origin'
+ });
+
+ const payload = await response.json().catch(() => ({
+ status: 'error',
+ message: translateLabel('event_symbol:invalid_server_response', 'Invalid server response.')
+ }));
+
+ if (!response.ok || !payload || payload.status !== 'success') {
+ const message =
+ payload && payload.message
+ ? payload.message
+ : translateLabel('event_symbol:request_failed', 'Request failed.');
+ throw new Error(message);
+ }
+
+ return payload;
+}
+
+async function uploadCustomImage(picker) {
+ const fileInput = picker.querySelector('.adminIconSelection-uploadInput');
+ const uploadButton = picker.querySelector('.adminIconSelection-uploadBtn');
+
+ if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
+ setUploadStatus(
+ picker,
+ translateLabel('event_symbol:upload_select_file_first', 'Please select a file first.'),
+ 'error'
+ );
+ return;
+ }
+
+ const file = fileInput.files[0];
+ const data = new FormData();
+ data.append('type', 'event_symbol_upload');
+ data.append(ICON_UPLOAD_FIELD, file);
+
+ if (uploadButton) {
+ uploadButton.disabled = true;
+ }
+
+ setUploadStatus(picker, translateLabel('event_symbol:upload_in_progress', 'Uploading...'), 'info');
+
+ try {
+ const payload = await postIconAction(data);
+ const entry = createEntryFromCatalogItem(payload.icon || null);
+ if (entry) {
+ upsertEntry(picker, entry);
+ picker._iconPickerState.activeCategory = CUSTOM_IMAGE_CATEGORY;
+ updateCategoryButtons(picker, CUSTOM_IMAGE_CATEGORY);
+ renderIconGridForPicker(picker);
+ setPickerIcon(picker, entry.value);
+ setUploadStatus(
+ picker,
+ payload.message || translateLabel('event_symbol:upload_success', 'Image uploaded.'),
+ 'success'
+ );
+ } else {
+ setUploadStatus(
+ picker,
+ translateLabel(
+ 'event_symbol:upload_success_but_unreadable',
+ 'Upload succeeded, but icon entry could not be read.'
+ ),
+ 'error'
+ );
+ }
+
+ fileInput.value = '';
+ updateUploadFileName(picker);
+ } catch (error) {
+ setUploadStatus(
+ picker,
+ error.message || translateLabel('event_symbol:upload_failed', 'Upload failed.'),
+ 'error'
+ );
+ } finally {
+ if (uploadButton) {
+ uploadButton.disabled = false;
+ }
+ }
+}
+
+async function deleteCustomImage(picker, value) {
+ const normalizedValue = normalizeEventSymbolValue(value);
+ if (!isCustomImageValue(normalizedValue)) {
+ setUploadStatus(
+ picker,
+ translateLabel('event_symbol:no_custom_image_selected', 'No custom image is currently selected.'),
+ 'error'
+ );
+ return;
+ }
+
+ const data = new FormData();
+ data.append('type', 'event_symbol_delete');
+ data.append('value', normalizedValue);
+
+ setUploadStatus(picker, translateLabel('event_symbol:delete_in_progress', 'Deleting image...'), 'info');
+
+ try {
+ const payload = await postIconAction(data);
+ removeEntryByValue(picker, normalizedValue);
+
+ const input = picker.querySelector('.adminIconSelection-input');
+ if (input && normalizeEventSymbolValue(input.value) === normalizedValue) {
+ setPickerIcon(picker, 'camera');
+ }
+
+ setUploadStatus(
+ picker,
+ payload.message || translateLabel('event_symbol:delete_success', 'Image deleted.'),
+ 'success'
+ );
+ } catch (error) {
+ setUploadStatus(
+ picker,
+ error.message || translateLabel('event_symbol:delete_failed', 'Delete failed.'),
+ 'error'
+ );
+ }
+}
+
+function initAdminIconSelection(picker) {
+ if (!picker || picker.getAttribute('data-icon-picker-init') === '1') {
+ return;
+ }
+
+ const entries = buildPickerEntries(picker);
+ const categories = buildCategoryList(picker, entries);
+ const activeCategory = categories.some((item) => item.id === 'all') ? 'all' : categories[0]?.id || 'all';
+
+ picker._iconPickerState = {
+ entries: entries,
+ categories: categories,
+ activeCategory: activeCategory,
+ searchQuery: ''
+ };
+
+ renderCategoryButtons(picker);
+
+ const searchInput = picker.querySelector('.adminIconSelection-search');
+ if (searchInput) {
+ searchInput.addEventListener('input', function () {
+ picker._iconPickerState.searchQuery = this.value || '';
+ renderIconGridForPicker(picker);
+ });
+ }
+
+ const valueInput = picker.querySelector('.adminIconSelection-input');
+ if (valueInput) {
+ valueInput.addEventListener('change', function () {
+ const normalized = normalizeEventSymbolValue(this.value || 'camera');
+ this.value = normalized;
+ updatePickerPreview(picker, normalized);
+ renderIconGridForPicker(picker);
+ });
+
+ valueInput.value = normalizeEventSymbolValue(valueInput.value || 'camera');
+ updatePickerPreview(picker, valueInput.value);
+ }
+
+ const directInput = picker.querySelector('.adminIconSelection-directInput');
+ const directApply = picker.querySelector('.adminIconSelection-directApply');
+
+ if (directInput && directApply) {
+ directApply.addEventListener('click', function () {
+ setPickerIcon(picker, directInput.value || 'camera');
+ renderIconGridForPicker(picker);
+ });
+
+ directInput.addEventListener('keydown', function (event) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ setPickerIcon(picker, directInput.value || 'camera');
+ renderIconGridForPicker(picker);
+ }
+ });
+ }
+
+ const uploadButton = picker.querySelector('.adminIconSelection-uploadBtn');
+ const uploadInput = picker.querySelector('.adminIconSelection-uploadInput');
+ if (uploadInput) {
+ uploadInput.addEventListener('change', function () {
+ updateUploadFileName(picker);
+ });
+ updateUploadFileName(picker);
+ }
+
+ if (uploadButton) {
+ uploadButton.addEventListener('click', function () {
+ uploadCustomImage(picker);
+ });
+ }
+
+ const deleteButton = picker.querySelector('.adminIconSelection-deleteImage');
+ if (deleteButton) {
+ deleteButton.addEventListener('click', function () {
+ const selectedValue =
+ this.getAttribute('data-icon-value') || picker.querySelector('.adminIconSelection-input')?.value || '';
+ deleteCustomImage(picker, selectedValue);
+ });
+ }
+
+ updateCategoryButtons(picker, activeCategory);
+ renderIconGridForPicker(picker);
+ picker.setAttribute('data-icon-picker-init', '1');
+}
+
+function initAllAdminIconSelections() {
+ document.querySelectorAll('.adminIconSelection').forEach((picker) => {
+ initAdminIconSelection(picker);
+ });
+}
+
+// eslint-disable-next-line no-unused-vars
+function openAdminIconSelect(element) {
+ const picker = element.closest('.adminIconSelection');
+ if (!picker) {
+ return;
+ }
+
+ initAdminIconSelection(picker);
+ picker.classList.add('isOpen');
+
+ const searchInput = picker.querySelector('.adminIconSelection-search');
+ if (searchInput) {
+ searchInput.focus();
+ }
+
+ const directInput = picker.querySelector('.adminIconSelection-directInput');
+ const currentValue = picker.querySelector('.adminIconSelection-input')?.value || '';
+ if (directInput) {
+ directInput.value = normalizeEventSymbolValue(currentValue);
+ }
+}
+
+function closeAdminIconSelect() {
+ document.querySelectorAll('.adminIconSelection').forEach((picker) => {
+ picker.classList.remove('isOpen');
+ });
+}
+
+$(function () {
+ initAllAdminIconSelections();
+ renderLucideIcons();
+});
diff --git a/assets/js/core.js b/assets/js/core.js
index 55d412497..295c12322 100644
--- a/assets/js/core.js
+++ b/assets/js/core.js
@@ -142,6 +142,14 @@ const photoBooth = (function () {
api.init = function () {
api.reset();
startPage.addClass('stage--active');
+ if (window.lucide && window.lucide.createIcons && window.lucide.icons) {
+ window.lucide.createIcons({
+ icons: window.lucide.icons
+ });
+ document.querySelectorAll('[data-lucide] svg').forEach((svg) => {
+ svg.setAttribute('stroke-width', '2.8');
+ });
+ }
if (usesBackgroundPreview) {
photoboothPreview.startVideo(CameraDisplayMode.BACKGROUND);
photoboothTools.console.logDev('Preview: core: start video (BACKGROUND) from api.init.');
diff --git a/assets/js/screensaver.js b/assets/js/screensaver.js
index b5846f2e7..8adea8692 100644
--- a/assets/js/screensaver.js
+++ b/assets/js/screensaver.js
@@ -92,11 +92,252 @@
const g = parseInt(fullHex.substring(2, 4), 16) || 0;
const b = parseInt(fullHex.substring(4, 6), 16) || 0;
const screensaverBackdrop = `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
+ const customImagePrefix = 'image:';
+ const customImageDirectory = 'private/images/event-symbols/';
+ const legacyMap = {
+ 'fa-camera': 'camera',
+ 'fa-camera-retro': 'camera',
+ 'fa-birthday-cake': 'cake',
+ 'fa-gift': 'gift',
+ 'fa-tree': 'tree-pine',
+ 'fa-snowflake': 'snowflake',
+ 'fa-heart-o': 'heart',
+ 'fa-regular fa-heart': 'heart',
+ 'fa-solid fa-heart': 'heart',
+ 'fa-solid fa-heart-pulse': 'heart-pulse',
+ 'fa-solid fa-sun': 'sun',
+ 'fa-brands fa-apple': 'apple',
+ 'fa-anchor': 'anchor',
+ 'fa-light fa-champagne-glasses': 'party-popper',
+ 'fa-champagne-glasses': 'party-popper',
+ 'fa-gears': 'cog',
+ 'fa-cogs': 'cog',
+ 'fa-users': 'users'
+ };
+ const isFontAwesomeValue = (iconName) => {
+ const value = String(iconName || '')
+ .trim()
+ .toLowerCase();
+ if (!value) {
+ return false;
+ }
+ if (legacyMap[value]) {
+ return true;
+ }
+ return /(^|\s)fa($|\s)|fa-[a-z0-9-]+/.test(value);
+ };
+ const looksLikeCustomImagePath = (iconName) => {
+ const normalized = String(iconName || '')
+ .trim()
+ .replace(/\\/g, '/')
+ .replace(/^\/+/, '')
+ .toLowerCase();
+ return normalized.indexOf(customImageDirectory) === 0;
+ };
+ const normalizeCustomImageValue = (iconName) => {
+ let raw = String(iconName || '').trim();
+ if (!raw) {
+ return '';
+ }
+
+ if (raw.toLowerCase().startsWith(customImagePrefix)) {
+ raw = raw.slice(customImagePrefix.length);
+ }
+
+ raw = raw
+ .replace(/\\/g, '/')
+ .replace(/^\/+/, '')
+ .replace(/\/{2,}/g, '/');
+ if (!raw || raw.indexOf('..') !== -1 || raw.indexOf('\0') !== -1) {
+ return '';
+ }
+
+ if (!/^[A-Za-z0-9._/-]+$/.test(raw)) {
+ return '';
+ }
+
+ if (raw.toLowerCase().indexOf(customImageDirectory) !== 0) {
+ return '';
+ }
+
+ if (!/\.(svg|png|jpe?g|webp|gif|avif)$/i.test(raw)) {
+ return '';
+ }
+
+ return `${customImagePrefix}${raw}`;
+ };
+ const isCustomImageValue = (iconName) => normalizeCustomImageValue(iconName) !== '';
+ const sanitizeFontAwesomeClasses = (iconName) => {
+ const raw = String(iconName || '')
+ .trim()
+ .toLowerCase()
+ .replace(/^fa:/, '');
+ if (!raw) {
+ return '';
+ }
+ const styleClasses = [
+ 'fa-solid',
+ 'fa-regular',
+ 'fa-brands',
+ 'fa-light',
+ 'fa-thin',
+ 'fa-sharp',
+ 'fa-classic'
+ ];
+ const unique = {};
+ const classes = [];
+ let hasIconClass = false;
+ raw.split(/\s+/).forEach((token) => {
+ const t = token.trim();
+ if (!t || (!t.startsWith('fa-') && t !== 'fa') || unique[t]) {
+ return;
+ }
+ unique[t] = true;
+ classes.push(t);
+ if (t.startsWith('fa-') && styleClasses.indexOf(t) === -1) {
+ hasIconClass = true;
+ }
+ });
+ if (!hasIconClass) {
+ return '';
+ }
+ if (!unique.fa) {
+ classes.unshift('fa');
+ }
+ return classes.join(' ');
+ };
+ const isIconifyValue = (iconName) => {
+ const raw = String(iconName || '')
+ .trim()
+ .toLowerCase();
+ if (!raw) {
+ return false;
+ }
+ const cleaned = raw.startsWith('iconify:') ? raw.slice(8) : raw;
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9][a-z0-9._-]*$/.test(cleaned);
+ };
+ const normalizeIconifyValue = (iconName) => {
+ let raw = String(iconName || '')
+ .trim()
+ .toLowerCase();
+ if (!raw) {
+ return '';
+ }
+ raw = raw.startsWith('iconify:') ? raw.slice(8) : raw;
+ const parts = raw.split(':');
+ if (parts.length !== 2) {
+ return '';
+ }
+
+ const prefix = parts[0]
+ .replace(/[^a-z0-9-]+/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ const icon = parts[1]
+ .replace(/[^a-z0-9._-]+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^[-._]+|[-._]+$/g, '');
+
+ return prefix && icon ? `${prefix}:${icon}` : '';
+ };
+ const buildPublicPath = (relativePath) => {
+ const normalized = String(relativePath || '').replace(/^\/+/, '');
+ if (!normalized) {
+ return '';
+ }
+
+ const base = environment && typeof environment.baseUrl === 'string' ? environment.baseUrl : '/';
+ const basePath = base.endsWith('/') ? base : `${base}/`;
+ return new URL(normalized, `${window.location.origin}${basePath}`).toString();
+ };
+ const escapeAttribute = (value) =>
+ String(value || '')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+ const normalizeEventSymbol = (iconName) => {
+ let value = String(iconName || '').trim();
+ if (!value) {
+ return 'camera';
+ }
+
+ if (value.toLowerCase().startsWith(customImagePrefix) || looksLikeCustomImagePath(value)) {
+ const customImage = normalizeCustomImageValue(value);
+ return customImage || 'camera';
+ }
+
+ const lower = value.toLowerCase();
+ if (lower.indexOf('lucide:') === 0) {
+ value = value.slice(7);
+ } else if (lower.indexOf('fa:') === 0) {
+ value = value.slice(3);
+ } else if (lower.indexOf('iconify:') === 0) {
+ value = value.slice(8);
+ }
+
+ if (isFontAwesomeValue(value)) {
+ return sanitizeFontAwesomeClasses(value) || 'fa fa-camera';
+ }
+
+ if (isIconifyValue(value)) {
+ const iconifyName = normalizeIconifyValue(value);
+ return iconifyName ? `iconify:${iconifyName}` : 'iconify:mdi:camera';
+ }
+
+ return String(value || '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9-]+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '');
+ };
+ const renderEventSymbol = (symbolValue) => {
+ if (!symbolValue) {
+ return '';
+ }
+ if (isCustomImageValue(symbolValue)) {
+ const imageValue = normalizeCustomImageValue(symbolValue);
+ if (!imageValue) {
+ return '';
+ }
+ const relativePath = imageValue.slice(customImagePrefix.length);
+ const publicPath = buildPublicPath(relativePath);
+ if (!publicPath) {
+ return '';
+ }
+ return `
`;
+ }
+ if (isFontAwesomeValue(symbolValue)) {
+ const classes = sanitizeFontAwesomeClasses(symbolValue);
+ return classes ? `` : '';
+ }
+ if (isIconifyValue(symbolValue)) {
+ const iconifyName = normalizeIconifyValue(symbolValue);
+ if (!iconifyName) {
+ return '';
+ }
+ return ``;
+ }
+ const lucideName = String(symbolValue || '')
+ .trim()
+ .toLowerCase();
+ return ``;
+ };
+ const renderLucideIcons = () => {
+ if (!window.lucide || !window.lucide.createIcons || !window.lucide.icons) {
+ return;
+ }
+ window.lucide.createIcons({
+ icons: window.lucide.icons
+ });
+ $('.screensaver-overlay [data-lucide] svg').attr('stroke-width', '2.8');
+ };
const buildEventText = () => {
const left = config.event.textLeft || '';
const right = config.event.textRight || '';
- const symbolClass = config.event.symbol || '';
- const symbol = symbolClass ? `` : '';
+ const symbolValue = normalizeEventSymbol(config.event.symbol || '');
+ const symbol = renderEventSymbol(symbolValue);
return [left, symbol, right].filter(Boolean).join(' ').trim();
};
@@ -178,6 +419,7 @@
resetSlots();
}
}
+ renderLucideIcons();
screensaverFlip = !screensaverFlip;
};
diff --git a/assets/sass/components/_screensaver.scss b/assets/sass/components/_screensaver.scss
index f1edb8c2f..5d01329fc 100644
--- a/assets/sass/components/_screensaver.scss
+++ b/assets/sass/components/_screensaver.scss
@@ -54,6 +54,24 @@
background: rgba(32, 32, 32, 0.55);
backdrop-filter: blur(3px);
+ .lucide,
+ .screensaver-event-icon,
+ iconify-icon.screensaver-event-icon {
+ width: 1em;
+ height: 1em;
+ vertical-align: -0.1em;
+ display: inline-block;
+ }
+
+ .lucide,
+ .screensaver-event-icon svg {
+ stroke-width: 2.8;
+ }
+
+ .screensaver-event-image {
+ object-fit: contain;
+ }
+
&--top {
top: 3rem;
}
diff --git a/assets/sass/components/_stage.scss b/assets/sass/components/_stage.scss
index 4baa7d833..948b2f556 100644
--- a/assets/sass/components/_stage.scss
+++ b/assets/sass/components/_stage.scss
@@ -299,6 +299,24 @@
color: var(--event-text-color, inherit);
font-weight: var(--event-text-weight, 400);
font-style: var(--event-text-style, normal);
+
+ .lucide,
+ .event-symbol-icon,
+ iconify-icon.event-symbol-icon {
+ width: 1em;
+ height: 1em;
+ vertical-align: -0.125em;
+ display: inline-block;
+ }
+
+ .lucide,
+ .event-symbol-icon svg {
+ stroke-width: 2.8;
+ }
+
+ .event-symbol-image {
+ object-fit: contain;
+ }
}
@media (max-width: 767px) {
diff --git a/assets/sass/tailwind.admin.scss b/assets/sass/tailwind.admin.scss
index 084e52446..318804963 100644
--- a/assets/sass/tailwind.admin.scss
+++ b/assets/sass/tailwind.admin.scss
@@ -14,6 +14,97 @@ body {
.navItem.isActive span.corner {
@apply flex;
}
+
+ .adminIconSelection-iconWrap {
+ @apply inline-flex items-center justify-center shrink-0 overflow-hidden align-middle;
+ line-height: 1;
+ }
+
+ .adminIconSelection-iconWrap--preview {
+ width: 3rem;
+ height: 3rem;
+ }
+
+ .adminIconSelection-iconWrap--grid {
+ width: 2.1rem;
+ height: 2.1rem;
+ }
+
+ .adminIconSelection-lucideIcon,
+ .adminIconSelection-iconWrap > svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+ }
+
+ .adminIconSelection-iconifyIcon {
+ display: inline-block;
+ width: 1em;
+ height: 1em;
+ line-height: 1;
+ font-size: 2.45rem;
+ }
+
+ .adminIconSelection-iconWrap--grid .adminIconSelection-iconifyIcon {
+ font-size: 1.8rem;
+ }
+
+ .adminIconSelection-faIcon {
+ font-size: 2.45rem;
+ line-height: 1;
+ width: 1em;
+ text-align: center;
+ }
+
+ .adminIconSelection-iconWrap--grid .adminIconSelection-faIcon {
+ font-size: 1.7rem;
+ }
+
+ .adminIconSelection-imageIcon {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ display: block;
+ }
+
+ .adminIconSelection-actionBtn {
+ @apply h-10 w-full rounded-md border-2 border-brand-1 px-4 text-sm font-semibold text-brand-1 transition hover:bg-brand-1 hover:text-white;
+ }
+
+ @media (min-width: 1024px) {
+ .adminIconSelection-actionBtn {
+ width: 11rem;
+ min-width: 11rem;
+ }
+ }
+
+ .adminIconSelection-directInputField {
+ @apply w-full h-10 border-2 border-solid border-gray-300 focus:border-brand-1 rounded-md px-3 text-sm font-mono;
+ }
+
+ .adminIconSelection-uploadField {
+ @apply w-full h-10 rounded-md border-2 border-brand-1 bg-blue-50/70 px-1 flex items-center gap-2 overflow-hidden;
+ }
+
+ .adminIconSelection-uploadTrigger {
+ @apply inline-flex h-8 shrink-0 items-center rounded-md bg-brand-1 px-3 text-xs font-bold text-white cursor-pointer transition hover:opacity-90;
+ }
+
+ .adminIconSelection-uploadFileName {
+ @apply min-w-0 flex-1 truncate text-sm text-gray-700 pr-2;
+ }
+
+ .adminIconSelection-uploadFileName.is-empty {
+ @apply text-gray-500;
+ }
+
+ .adminIconSelection-uploadInput:focus + .adminIconSelection-uploadTrigger {
+ @apply ring-2 ring-brand-1/30 ring-offset-1;
+ }
+
+ .adminIconSelection-itemDelete {
+ @apply mt-1 w-full rounded-md border border-rose-300 bg-rose-50 px-2 py-1 text-[10px] font-semibold text-rose-700 transition hover:bg-rose-100;
+ }
}
code {
diff --git a/gulpfile.mjs b/gulpfile.mjs
index 7f0708a3a..5fb50e3e5 100644
--- a/gulpfile.mjs
+++ b/gulpfile.mjs
@@ -98,6 +98,7 @@ gulp.task('js-admin', function () {
'./assets/js/admin/imageSelect.js',
'./assets/js/admin/fontSelect.js',
'./assets/js/admin/videoSelect.js',
+ './assets/js/admin/iconSelect.js',
'./assets/js/admin/themes.js',
'./assets/js/admin/toast.js',
])
diff --git a/lib/configsetup.inc.php b/lib/configsetup.inc.php
index 8c3a73942..e98eaf2cf 100644
--- a/lib/configsetup.inc.php
+++ b/lib/configsetup.inc.php
@@ -8,6 +8,7 @@
use Photobooth\Service\ConfigurationService;
use Photobooth\Service\LanguageService;
use Photobooth\Service\PrintManagerService;
+use Photobooth\Utility\EventSymbolUtility;
use Photobooth\Utility\PathUtility;
use Photobooth\Utility\StartpageTextPosition;
@@ -446,28 +447,11 @@
],
'event_symbol' => [
'view' => 'basic',
- 'type' => 'select',
+ 'type' => 'lucide-icon',
'name' => 'event[symbol]',
- 'placeholder' => $defaultConfig['event']['symbol'],
- 'options' => [
- 'fa-camera' => 'Camera',
- 'fa-camera-retro' => 'Camera Retro',
- 'fa-birthday-cake' => 'Birthday Cake',
- 'fa-gift' => 'Gift',
- 'fa-tree' => 'Tree',
- 'fa-snowflake' => 'Snowflake',
- 'fa-regular fa-heart' => 'Heart',
- 'fa-solid fa-heart' => 'Heart filled',
- 'fa-solid fa-heart-pulse' => 'Heartbeat',
- 'fa-brands fa-apple' => 'Apple',
- 'fa-anchor' => 'Anchor',
- 'fa-light fa-champagne-glasses' => 'Champagne glasses',
- 'fa-gears' => 'Gears',
- 'fa-users' => 'People',
- 'fa-solid fa-sun' => 'Sun',
- ],
+ 'placeholder' => EventSymbolUtility::normalize($defaultConfig['event']['symbol'] ?? 'camera'),
'data-theme-field' => 'true',
- 'value' => $config['event']['symbol'],
+ 'value' => EventSymbolUtility::normalize($config['event']['symbol'] ?? ''),
],
'event_textRight' => [
'view' => 'basic',
diff --git a/package-lock.json b/package-lock.json
index c264b9880..f22e92a8b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,9 @@
"gulp-dart-sass": "^1.1.0",
"gulp-filter": "^9.0.1",
"gulp-rename": "^2.1.0",
+ "iconify-icon": "^3.0.2",
"jquery": "^3.7.1",
+ "lucide": "^1.16.0",
"marvinj": "^1.0.0",
"material-icons": "^1.13.14",
"node-sass-importer": "^2.0.2",
@@ -1886,6 +1888,12 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "license": "MIT"
+ },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -5203,6 +5211,18 @@
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="
},
+ "node_modules/iconify-icon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-3.0.2.tgz",
+ "integrity": "sha512-DYPAumiUeUeT/GHT8x2wrAVKn1FqZJqFH0Y5pBefapWRreV1BBvqBVMb0020YQ2njmbR59r/IathL2d2OrDrxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@iconify/types": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyberalien"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -6212,6 +6232,12 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.16.0.tgz",
+ "integrity": "sha512-20QvduCJTB7e7K9WVvoLBuKPsYZ8d6ptwe9PIdTFiZmjVTPdFWQreBNyNCM9QQtGnqHR0PLiEQdxyhYsL1LdoA==",
+ "license": "ISC"
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
diff --git a/package.json b/package.json
index 9a8619a95..c643a4040 100644
--- a/package.json
+++ b/package.json
@@ -63,7 +63,9 @@
"gulp-dart-sass": "^1.1.0",
"gulp-filter": "^9.0.1",
"gulp-rename": "^2.1.0",
+ "iconify-icon": "^3.0.2",
"jquery": "^3.7.1",
+ "lucide": "^1.16.0",
"marvinj": "^1.0.0",
"material-icons": "^1.13.14",
"node-sass-importer": "^2.0.2",
diff --git a/resources/lang/de.json b/resources/lang/de.json
index aa8f36319..67885b3d1 100644
--- a/resources/lang/de.json
+++ b/resources/lang/de.json
@@ -152,6 +152,57 @@
"error_path_noImages": "Der Pfad enthält keine Fotos",
"error_path_noVideos": "Der Pfad enthält keine Videos",
"event": "Veranstaltungen",
+ "event_symbol:api_custom_icon_create_failed": "Custom-Icon konnte nicht erstellt werden.",
+ "event_symbol:api_delete_success": "Bild erfolgreich gelöscht.",
+ "event_symbol:api_file_delete_failed": "Datei konnte nicht gelöscht werden.",
+ "event_symbol:api_file_empty_or_too_large": "Datei ist leer oder zu groß (max. 8 MB).",
+ "event_symbol:api_file_not_found": "Datei nicht gefunden.",
+ "event_symbol:api_file_save_failed": "Datei konnte nicht gespeichert werden.",
+ "event_symbol:api_file_type_not_allowed": "Dateityp nicht erlaubt.",
+ "event_symbol:api_invalid_custom_image_selection": "Kein gültiges Custom-Bild ausgewählt.",
+ "event_symbol:api_invalid_file_path": "Ungültiger Dateipfad.",
+ "event_symbol:api_invalid_mime_type": "Ungültiger MIME-Type.",
+ "event_symbol:api_invalid_upload_file": "Ungültige Upload-Datei.",
+ "event_symbol:api_no_file_uploaded": "Keine Datei zum Hochladen gefunden.",
+ "event_symbol:api_upload_dir_create_failed": "Upload-Verzeichnis konnte nicht erstellt werden.",
+ "event_symbol:api_upload_dir_not_writable": "Upload-Verzeichnis ist nicht beschreibbar.",
+ "event_symbol:api_upload_failed_code": "Upload fehlgeschlagen (Fehlercode: %d).",
+ "event_symbol:api_upload_success": "Bild erfolgreich hochgeladen.",
+ "event_symbol:apply_direct": "Direkt übernehmen",
+ "event_symbol:category_all": "Alle",
+ "event_symbol:category_custom_images": "Eigene Bilder",
+ "event_symbol:category_event": "Event/Party",
+ "event_symbol:category_food": "Food/Drink",
+ "event_symbol:category_legacy": "Klassisch (FA)",
+ "event_symbol:category_love": "Liebe",
+ "event_symbol:category_music": "Musik",
+ "event_symbol:category_nature": "Natur/Wetter",
+ "event_symbol:category_people": "Menschen",
+ "event_symbol:category_photo": "Foto/Media",
+ "event_symbol:category_time": "Zeit/Ort",
+ "event_symbol:category_tools": "Tools",
+ "event_symbol:choose_icon": "Icon auswählen",
+ "event_symbol:delete_failed": "Löschen fehlgeschlagen.",
+ "event_symbol:delete_image": "Bild löschen",
+ "event_symbol:delete_in_progress": "Bild wird gelöscht...",
+ "event_symbol:delete_selected_image": "Ausgewähltes eigenes Bild löschen",
+ "event_symbol:delete_success": "Bild gelöscht.",
+ "event_symbol:direct_placeholder": "Direktname (z.B. square-user-round oder iconify:lets-icons:camera-fill)",
+ "event_symbol:hidden_icons_hint": "%d weitere Icons ausgeblendet. Bitte Suche oder Kategorie weiter einschränken.",
+ "event_symbol:invalid_server_response": "Ungültige Serverantwort.",
+ "event_symbol:no_custom_image_selected": "Aktuell ist kein eigenes Bild ausgewählt.",
+ "event_symbol:no_icons_found": "Keine Icons für diesen Filter gefunden.",
+ "event_symbol:request_failed": "Anfrage fehlgeschlagen.",
+ "event_symbol:search_placeholder": "Icon suchen...",
+ "event_symbol:upload_button": "Bild hochladen",
+ "event_symbol:upload_failed": "Upload fehlgeschlagen.",
+ "event_symbol:upload_in_progress": "Upload läuft...",
+ "event_symbol:upload_label": "Eigenes SVG/Bild hochladen",
+ "event_symbol:upload_no_file_selected": "Keine Datei ausgewählt",
+ "event_symbol:upload_select_file": "Datei auswählen",
+ "event_symbol:upload_select_file_first": "Bitte zuerst eine Datei auswählen.",
+ "event_symbol:upload_success": "Bild hochgeladen.",
+ "event_symbol:upload_success_but_unreadable": "Upload erfolgreich, Icon konnte nicht eingelesen werden.",
"file_upload_max_size": "Maximale Dateigröße: ",
"file_upload:error_cant_write": "Fehler beim Schreiben der Datei.",
"file_upload:error_extension": "Eine PHP-Erweiterung hat das Hochladen der Datei gestoppt.",
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 0453a9023..7a4fc7255 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -152,6 +152,57 @@
"error_path_noImages": "The path does not contain any images",
"error_path_noVideos": "The path does not contain any videos",
"event": "Event",
+ "event_symbol:api_custom_icon_create_failed": "Custom icon could not be created.",
+ "event_symbol:api_delete_success": "Image deleted successfully.",
+ "event_symbol:api_file_delete_failed": "File could not be deleted.",
+ "event_symbol:api_file_empty_or_too_large": "File is empty or too large (max. 8 MB).",
+ "event_symbol:api_file_not_found": "File not found.",
+ "event_symbol:api_file_save_failed": "File could not be saved.",
+ "event_symbol:api_file_type_not_allowed": "File type not allowed.",
+ "event_symbol:api_invalid_custom_image_selection": "No valid custom image selected.",
+ "event_symbol:api_invalid_file_path": "Invalid file path.",
+ "event_symbol:api_invalid_mime_type": "Invalid MIME type.",
+ "event_symbol:api_invalid_upload_file": "Invalid upload file.",
+ "event_symbol:api_no_file_uploaded": "No file found for upload.",
+ "event_symbol:api_upload_dir_create_failed": "Upload directory could not be created.",
+ "event_symbol:api_upload_dir_not_writable": "Upload directory is not writable.",
+ "event_symbol:api_upload_failed_code": "Upload failed (error code: %d).",
+ "event_symbol:api_upload_success": "Image uploaded successfully.",
+ "event_symbol:apply_direct": "Apply directly",
+ "event_symbol:category_all": "All",
+ "event_symbol:category_custom_images": "Custom images",
+ "event_symbol:category_event": "Event/Party",
+ "event_symbol:category_food": "Food/Drink",
+ "event_symbol:category_legacy": "Classic (FA)",
+ "event_symbol:category_love": "Love",
+ "event_symbol:category_music": "Music",
+ "event_symbol:category_nature": "Nature/Weather",
+ "event_symbol:category_people": "People",
+ "event_symbol:category_photo": "Photo/Media",
+ "event_symbol:category_time": "Time/Place",
+ "event_symbol:category_tools": "Tools",
+ "event_symbol:choose_icon": "Select icon",
+ "event_symbol:delete_failed": "Delete failed.",
+ "event_symbol:delete_image": "Delete image",
+ "event_symbol:delete_in_progress": "Deleting image...",
+ "event_symbol:delete_selected_image": "Delete selected custom image",
+ "event_symbol:delete_success": "Image deleted.",
+ "event_symbol:direct_placeholder": "Direct name (e.g. square-user-round or iconify:lets-icons:camera-fill)",
+ "event_symbol:hidden_icons_hint": "%d more icons hidden. Please narrow down search or category.",
+ "event_symbol:invalid_server_response": "Invalid server response.",
+ "event_symbol:no_custom_image_selected": "No custom image is currently selected.",
+ "event_symbol:no_icons_found": "No icons found for this filter.",
+ "event_symbol:request_failed": "Request failed.",
+ "event_symbol:search_placeholder": "Search icons...",
+ "event_symbol:upload_button": "Upload image",
+ "event_symbol:upload_failed": "Upload failed.",
+ "event_symbol:upload_in_progress": "Uploading...",
+ "event_symbol:upload_label": "Upload own SVG/image",
+ "event_symbol:upload_no_file_selected": "No file selected",
+ "event_symbol:upload_select_file": "Select file",
+ "event_symbol:upload_select_file_first": "Please select a file first.",
+ "event_symbol:upload_success": "Image uploaded.",
+ "event_symbol:upload_success_but_unreadable": "Upload succeeded, but icon entry could not be read.",
"file_upload_max_size": "Maximum single file size: ",
"file_upload:error_cant_write": "Failed to write file to disk.",
"file_upload:error_extension": "A PHP extension stopped the file upload.",
diff --git a/src/Configuration/Section/EventConfiguration.php b/src/Configuration/Section/EventConfiguration.php
index d1eb77563..7c59adee9 100644
--- a/src/Configuration/Section/EventConfiguration.php
+++ b/src/Configuration/Section/EventConfiguration.php
@@ -15,14 +15,7 @@ public static function getNode(): NodeDefinition
->booleanNode('enabled')->defaultValue(false)->end()
->scalarNode('textRight')->defaultValue('')->end()
->scalarNode('textLeft')->defaultValue('')->end()
- ->enumNode('symbol')
- ->values([
- 'fa-camera', 'fa-camera-retro', 'fa-birthday-cake', 'fa-gift', 'fa-tree', 'fa-snowflake',
- 'fa-regular fa-heart', 'fa-solid fa-heart', 'fa-solid fa-heart-pulse', 'fa-brands fa-apple',
- 'fa-anchor', 'fa-light fa-champagne-glasses', 'fa-gears', 'fa-users', 'fa-solid fa-sun'
- ])
- ->defaultValue('fa-regular fa-heart')
- ->end()
+ ->scalarNode('symbol')->defaultValue('fa-regular fa-heart')->end()
->end();
}
}
diff --git a/src/Service/ConfigurationService.php b/src/Service/ConfigurationService.php
index f504dfe71..6bd59d340 100644
--- a/src/Service/ConfigurationService.php
+++ b/src/Service/ConfigurationService.php
@@ -7,6 +7,7 @@
use Photobooth\Environment;
use Photobooth\Helper;
use Photobooth\Utility\ArrayUtility;
+use Photobooth\Utility\EventSymbolUtility;
use Photobooth\Utility\PathUtility;
use Symfony\Component\Config\Definition\Processor;
use Symfony\Component\Config\FileLocator;
@@ -318,6 +319,10 @@ protected function processMigration(array $config): array
}
}
+ if (isset($config['event']['symbol'])) {
+ $config['event']['symbol'] = EventSymbolUtility::normalize($config['event']['symbol']);
+ }
+
return $config;
}
diff --git a/src/Utility/AdminInput.php b/src/Utility/AdminInput.php
index 815b2bc7d..96f37768e 100644
--- a/src/Utility/AdminInput.php
+++ b/src/Utility/AdminInput.php
@@ -176,6 +176,183 @@ class="w-full h-10 border-2 border-solid border-gray-300 focus:border-brand-1 ro
';
}
+ public static function renderLucideIcon(array $setting, string $label): string
+ {
+ $languageService = LanguageService::getInstance();
+ $attributes = self::buildAttributes($setting);
+ $selectedIcon = EventSymbolUtility::normalize((string) ($setting['value'] ?? 'camera'));
+ $iconCatalog = EventIconCatalogUtility::getCatalog();
+ $categoryTranslationKeys = [
+ 'all' => 'event_symbol:category_all',
+ 'event' => 'event_symbol:category_event',
+ 'photo' => 'event_symbol:category_photo',
+ 'love' => 'event_symbol:category_love',
+ 'food' => 'event_symbol:category_food',
+ 'nature' => 'event_symbol:category_nature',
+ 'music' => 'event_symbol:category_music',
+ 'people' => 'event_symbol:category_people',
+ 'time' => 'event_symbol:category_time',
+ 'tools' => 'event_symbol:category_tools',
+ 'legacy' => 'event_symbol:category_legacy',
+ 'custom-images' => 'event_symbol:category_custom_images',
+ ];
+ foreach ($iconCatalog['categories'] as &$category) {
+ $categoryId = $category['id'];
+ if (isset($categoryTranslationKeys[$categoryId])) {
+ $category['title'] = $languageService->translate($categoryTranslationKeys[$categoryId]);
+ }
+ }
+ unset($category);
+ $catalogJson = (string) json_encode(
+ $iconCatalog,
+ JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT,
+ );
+ $legacyIconsJson = (string) json_encode(
+ EventSymbolUtility::getLegacyIconList(),
+ JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT,
+ );
+ $selectedIconEscaped = htmlspecialchars($selectedIcon, ENT_QUOTES);
+ $placeholder = htmlspecialchars((string) ($setting['placeholder'] ?? 'camera'), ENT_QUOTES);
+ $name = htmlspecialchars((string) $setting['name'], ENT_QUOTES);
+ $selectedType = EventSymbolUtility::getSymbolType($selectedIcon);
+ $selectedFaClasses = htmlspecialchars(EventSymbolUtility::getFontAwesomeClasses($selectedIcon), ENT_QUOTES);
+ $selectedLucideName = htmlspecialchars(EventSymbolUtility::getLucideFallback($selectedIcon), ENT_QUOTES);
+ $selectedIconifyName = htmlspecialchars(EventSymbolUtility::getIconifyName($selectedIcon), ENT_QUOTES);
+ $selectedImagePath = htmlspecialchars(EventSymbolUtility::getCustomImagePublicPath($selectedIcon), ENT_QUOTES);
+ if ($selectedType === 'fa') {
+ $previewIconMarkup = '';
+ } elseif ($selectedType === 'iconify') {
+ $previewIconMarkup = '';
+ } elseif ($selectedType === 'image' && $selectedImagePath !== '') {
+ $previewIconMarkup = '
';
+ } else {
+ $previewIconMarkup = '';
+ }
+ $searchPlaceholder = $languageService->translate('event_symbol:search_placeholder');
+ $chooseLabel = $languageService->translate('event_symbol:choose_icon');
+ $directPlaceholder = $languageService->translate('event_symbol:direct_placeholder');
+ $applyDirectLabel = $languageService->translate('event_symbol:apply_direct');
+ $uploadLabel = $languageService->translate('event_symbol:upload_label');
+ $uploadSelectFileLabel = $languageService->translate('event_symbol:upload_select_file');
+ $uploadNoFileSelectedLabel = $languageService->translate('event_symbol:upload_no_file_selected');
+ $uploadButtonLabel = $languageService->translate('event_symbol:upload_button');
+ $deleteCustomImageLabel = $languageService->translate('event_symbol:delete_selected_image');
+ $searchPlaceholderEscaped = htmlspecialchars($searchPlaceholder, ENT_QUOTES);
+ $chooseLabelEscaped = htmlspecialchars($chooseLabel, ENT_QUOTES);
+ $directPlaceholderEscaped = htmlspecialchars($directPlaceholder, ENT_QUOTES);
+ $applyDirectLabelEscaped = htmlspecialchars($applyDirectLabel, ENT_QUOTES);
+ $uploadLabelEscaped = htmlspecialchars($uploadLabel, ENT_QUOTES);
+ $uploadButtonLabelEscaped = htmlspecialchars($uploadButtonLabel, ENT_QUOTES);
+ $deleteCustomImageLabelEscaped = htmlspecialchars($deleteCustomImageLabel, ENT_QUOTES);
+ $uploadSelectFileLabelEscaped = htmlspecialchars($uploadSelectFileLabel, ENT_QUOTES);
+ $uploadNoFileSelectedLabelEscaped = htmlspecialchars($uploadNoFileSelectedLabel, ENT_QUOTES);
+ $allowedExtensions = EventSymbolUtility::getAllowedCustomImageExtensions();
+ $acceptValues = array_map(static fn (string $extension): string => '.' . $extension, $allowedExtensions);
+ $uploadAcceptAttribute = htmlspecialchars(implode(',', $acceptValues), ENT_QUOTES);
+ $uploadInputIdBase = preg_replace('/[^a-z0-9]+/i', '-', (string) ($setting['name'] ?? 'event-symbol-image'));
+ $uploadInputId = 'event-symbol-upload-' . trim((string) $uploadInputIdBase, '-');
+ if ($uploadInputId === 'event-symbol-upload-') {
+ $uploadInputId = 'event-symbol-upload-image';
+ }
+ $uploadInputId = htmlspecialchars($uploadInputId, ENT_QUOTES);
+
+ $categoryMarkup = '';
+ $categoryList = $iconCatalog['categories'];
+ foreach ($categoryList as $category) {
+ $categoryMarkup .= '
+
+ ';
+ }
+
+ return '
+
+ ' . self::renderHeadline($label) . '
+
+
+
+
' . $selectedIconEscaped . '
+
+
+
+
+
+
+
+
+
+
+
+
' . $chooseLabelEscaped . '
+
+
+
+
+
+
+
+
+
+
' . $uploadLabelEscaped . '
+
+
+
+ ' . $uploadNoFileSelectedLabelEscaped . '
+
+
+
+
+
+ ' . $categoryMarkup . '
+
+
+
+
+
+
+ ';
+ }
+
public static function renderRange(array $setting, string $label): string
{
$languageService = LanguageService::getInstance();
diff --git a/src/Utility/EventIconCatalogUtility.php b/src/Utility/EventIconCatalogUtility.php
new file mode 100644
index 000000000..d2c8eb407
--- /dev/null
+++ b/src/Utility/EventIconCatalogUtility.php
@@ -0,0 +1,989 @@
+
+ */
+ private const CATEGORY_DEFINITIONS = [
+ 'all' => ['title' => 'All', 'source' => 'system'],
+ 'event' => ['title' => 'Event/Party', 'source' => 'mixed'],
+ 'photo' => ['title' => 'Photo/Media', 'source' => 'mixed'],
+ 'love' => ['title' => 'Love', 'source' => 'mixed'],
+ 'food' => ['title' => 'Food/Drink', 'source' => 'mixed'],
+ 'nature' => ['title' => 'Nature/Weather', 'source' => 'mixed'],
+ 'music' => ['title' => 'Music', 'source' => 'mixed'],
+ 'people' => ['title' => 'People', 'source' => 'mixed'],
+ 'time' => ['title' => 'Time/Place', 'source' => 'mixed'],
+ 'tools' => ['title' => 'Tools', 'source' => 'mixed'],
+ 'legacy' => ['title' => 'Classic (FA)', 'source' => 'legacy'],
+ 'custom-images' => ['title' => 'Custom images', 'source' => 'custom'],
+ ];
+
+ /**
+ * Map Lucide categories to unified picker categories.
+ *
+ * @var array
+ */
+ private const LUCIDE_CATEGORY_TO_GROUPS = [
+ 'account' => ['people'],
+ 'animals' => ['nature'],
+ 'buildings' => ['time'],
+ 'emoji' => ['event', 'people'],
+ 'finance' => ['event'],
+ 'food-beverage' => ['food'],
+ 'home' => ['event'],
+ 'medical' => ['event'],
+ 'multimedia' => ['photo', 'music'],
+ 'nature' => ['nature'],
+ 'navigation' => ['time'],
+ 'notifications' => ['event'],
+ 'people' => ['people'],
+ 'photography' => ['photo'],
+ 'seasons' => ['nature'],
+ 'security' => ['tools'],
+ 'shapes' => ['tools'],
+ 'shopping' => ['event'],
+ 'social' => ['people', 'love'],
+ 'sports' => ['event'],
+ 'sustainability' => ['nature'],
+ 'time' => ['time'],
+ 'tools' => ['tools'],
+ 'transportation' => ['time'],
+ 'travel' => ['time'],
+ 'weather' => ['nature'],
+ ];
+
+ /**
+ * Keyword fallback mapping for both Lucide and Iconify items.
+ *
+ * @var array
+ */
+ private const CATEGORY_KEYWORDS = [
+ 'event' => ['party', 'balloon', 'confetti', 'sparkle', 'ticket', 'celebration', 'gift', 'crown', 'firework'],
+ 'photo' => ['camera', 'photo', 'video', 'selfie', 'flash', 'image', 'gallery', 'film'],
+ 'love' => ['heart', 'love', 'wedding', 'ring', 'rose', 'kiss', 'romance'],
+ 'food' => ['bbq', 'grill', 'cake', 'beer', 'wine', 'cocktail', 'food', 'drink', 'coffee', 'tea', 'pizza', 'burger'],
+ 'nature' => ['sun', 'beach', 'flower', 'tree', 'leaf', 'snow', 'nature', 'cloud', 'rain', 'weather', 'mountain'],
+ 'music' => ['music', 'dance', 'microphone', 'speaker', 'guitar', 'dj', 'headphone', 'note'],
+ 'people' => ['people', 'person', 'user', 'group', 'family', 'smile', 'friends'],
+ 'time' => ['calendar', 'clock', 'location', 'map', 'travel', 'pin', 'route', 'compass', 'time'],
+ 'tools' => ['settings', 'gear', 'cog', 'wrench', 'hammer', 'tool'],
+ ];
+
+ /**
+ * Iconify query buckets mapped to unified category IDs.
+ *
+ * @var array
+ */
+ private const ICONIFY_BUCKETS = [
+ 'photo' => ['camera', 'photo', 'video', 'selfie', 'flash'],
+ 'event' => ['party', 'balloon', 'confetti', 'sparkle', 'ticket', 'celebration'],
+ 'love' => ['heart', 'wedding', 'ring', 'rose', 'kiss'],
+ 'food' => ['bbq', 'grill', 'cake', 'beer', 'wine', 'cocktail', 'food'],
+ 'nature' => ['sun', 'beach', 'flower', 'tree', 'leaf', 'snow'],
+ 'music' => ['music', 'dance', 'microphone', 'speaker'],
+ 'people' => ['people', 'user', 'group', 'smile'],
+ 'time' => ['calendar', 'clock', 'location', 'map', 'travel'],
+ 'tools' => ['settings', 'gear', 'cog'],
+ ];
+
+ /**
+ * @return array{generatedAt:string,categories:array,icons:array,search:string}>}
+ */
+ public static function getCatalog(): array
+ {
+ $cachePath = PathUtility::getAbsolutePath(self::CACHE_FILE);
+ $cached = self::readCache($cachePath);
+
+ if ($cached !== null && self::isCacheFresh($cachePath)) {
+ return self::withCustomImages($cached);
+ }
+
+ $fresh = self::buildCatalog();
+ if ($fresh !== null) {
+ self::writeCache($cachePath, $fresh);
+ return self::withCustomImages($fresh);
+ }
+
+ if ($cached !== null) {
+ return self::withCustomImages($cached);
+ }
+
+ return self::withCustomImages(self::fallbackCatalog());
+ }
+
+ public static function invalidateCache(): void
+ {
+ $cachePath = PathUtility::getAbsolutePath(self::CACHE_FILE);
+ if (is_file($cachePath)) {
+ @unlink($cachePath);
+ }
+ }
+
+ public static function getCustomImageDirectoryRelative(): string
+ {
+ return EventSymbolUtility::getCustomImageDirectory();
+ }
+
+ public static function getCustomImageDirectoryAbsolute(): string
+ {
+ return PathUtility::getAbsolutePath(self::getCustomImageDirectoryRelative());
+ }
+
+ public static function isAllowedCustomImageExtension(string $extension): bool
+ {
+ return in_array(strtolower($extension), EventSymbolUtility::getAllowedCustomImageExtensions(), true);
+ }
+
+ /**
+ * @return array{provider:string,value:string,label:string,categories:array,search:string}|null
+ */
+ public static function buildCustomImageEntry(string $relativePath): ?array
+ {
+ $normalized = EventSymbolUtility::normalizeCustomImageValue('image:' . ltrim($relativePath, '/'));
+ if ($normalized === '') {
+ return null;
+ }
+
+ $path = EventSymbolUtility::getCustomImagePath($normalized);
+ if ($path === '') {
+ return null;
+ }
+
+ $baseName = pathinfo($path, PATHINFO_FILENAME);
+ $label = self::humanizeIconifyName((string) $baseName);
+ if ($label === '') {
+ $label = basename($path);
+ }
+
+ return [
+ 'provider' => 'image',
+ 'value' => $normalized,
+ 'label' => $label,
+ 'categories' => ['custom-images'],
+ 'search' => strtolower($label . ' ' . $path . ' custom image'),
+ ];
+ }
+
+ /**
+ * @return array{generatedAt:string,categories:array,icons:array,search:string}>}|null
+ */
+ private static function buildCatalog(): ?array
+ {
+ $lucideIconNames = LucideIconUtility::getIconNames();
+ if ($lucideIconNames === []) {
+ return null;
+ }
+
+ $lucideCategoriesByIcon = self::fetchJson(self::LUCIDE_CATEGORIES_URL);
+ $lucideTagsByIcon = self::fetchJson(self::LUCIDE_TAGS_URL);
+ if (!is_array($lucideCategoriesByIcon) || !is_array($lucideTagsByIcon)) {
+ return null;
+ }
+
+ $lucideEntries = self::buildLucideEntries(
+ $lucideIconNames,
+ $lucideCategoriesByIcon,
+ $lucideTagsByIcon,
+ );
+
+ $iconifyEntries = self::buildIconifyEntries();
+ $icons = array_merge($lucideEntries, $iconifyEntries);
+
+ usort($icons, static function (array $a, array $b): int {
+ if ($a['provider'] !== $b['provider']) {
+ return strcmp($a['provider'], $b['provider']);
+ }
+ return strcmp($a['label'], $b['label']);
+ });
+
+ return [
+ 'generatedAt' => gmdate(DATE_ATOM),
+ 'categories' => self::buildBaseCategories(),
+ 'icons' => $icons,
+ ];
+ }
+
+ /**
+ * @param string[] $iconNames
+ * @param array $categoriesByIcon
+ * @param array $tagsByIcon
+ *
+ * @return array,search:string}>
+ */
+ private static function buildLucideEntries(
+ array $iconNames,
+ array $categoriesByIcon,
+ array $tagsByIcon,
+ ): array {
+ $allowedCategories = array_fill_keys(self::LUCIDE_ALLOWED_CATEGORIES, true);
+ $entries = [];
+
+ foreach ($iconNames as $iconName) {
+ $name = EventSymbolUtility::normalizeLucideName($iconName);
+ if ($name === '') {
+ continue;
+ }
+
+ $iconCategoriesRaw = $categoriesByIcon[$name] ?? [];
+ $iconCategories = [];
+ if (is_array($iconCategoriesRaw)) {
+ foreach ($iconCategoriesRaw as $category) {
+ $category = EventSymbolUtility::normalizeLucideName((string) $category);
+ if ($category === '' || !isset($allowedCategories[$category])) {
+ continue;
+ }
+ $iconCategories[$category] = true;
+ }
+ }
+
+ $iconTagsRaw = $tagsByIcon[$name] ?? [];
+ $tags = [];
+ if (is_array($iconTagsRaw)) {
+ foreach ($iconTagsRaw as $tag) {
+ $tag = trim(strtolower((string) $tag));
+ if ($tag !== '') {
+ $tags[] = $tag;
+ }
+ }
+ }
+
+ if (!self::isRelevantIcon($name, array_keys($iconCategories), $tags)) {
+ continue;
+ }
+
+ $categoryIds = self::resolveUnifiedCategories($name, array_keys($iconCategories), $tags);
+ if ($categoryIds === []) {
+ $categoryIds = ['event'];
+ }
+
+ $label = self::humanizeSlug($name);
+ $searchParts = array_merge([$name, strtolower($label)], $categoryIds, array_keys($iconCategories), $tags);
+ $entries[] = [
+ 'provider' => 'lucide',
+ 'value' => $name,
+ 'label' => $label,
+ 'categories' => $categoryIds,
+ 'search' => implode(' ', array_values(array_unique(array_filter($searchParts, static fn (string $value): bool => $value !== '')))),
+ ];
+ }
+
+ return $entries;
+ }
+
+ /**
+ * @return array,search:string}>
+ */
+ private static function buildIconifyEntries(): array
+ {
+ $entries = [];
+ $seen = [];
+ $prefixQuota = [];
+
+ foreach (self::ICONIFY_BUCKETS as $categoryId => $queries) {
+ foreach ($queries as $query) {
+ $data = self::fetchIconifySearch($query);
+ if ($data === null) {
+ continue;
+ }
+
+ $collections = $data['collections'];
+ foreach ($data['icons'] as $iconName) {
+ $parts = explode(':', $iconName, 2);
+ if (count($parts) !== 2) {
+ continue;
+ }
+
+ $prefix = strtolower($parts[0]);
+ $name = strtolower($parts[1]);
+ $fullName = $prefix . ':' . $name;
+
+ if (!isset($collections[$prefix])) {
+ continue;
+ }
+
+ $collectionInfo = $collections[$prefix];
+ if (($collectionInfo['palette'] ?? true) === true || !self::isAllowedIconifyCollection($collectionInfo)) {
+ continue;
+ }
+
+ if (self::isBlockedIconName($name) || self::isBlockedIconName($fullName)) {
+ continue;
+ }
+
+ if (isset($prefixQuota[$prefix]) && $prefixQuota[$prefix] >= self::ICONIFY_MAX_PER_PREFIX) {
+ continue;
+ }
+
+ if (!isset($seen[$fullName])) {
+ $label = self::humanizeIconifyName($name);
+ $seen[$fullName] = [
+ 'provider' => 'iconify',
+ 'value' => 'iconify:' . $fullName,
+ 'label' => $label,
+ 'categories' => [],
+ 'search' => trim($fullName . ' ' . $label . ' ' . $query),
+ ];
+
+ $prefixQuota[$prefix] = ($prefixQuota[$prefix] ?? 0) + 1;
+ }
+
+ if (!in_array($categoryId, $seen[$fullName]['categories'], true)) {
+ $seen[$fullName]['categories'][] = $categoryId;
+ }
+
+ if (count($seen) >= self::ICONIFY_MAX_RENDERABLE_POOL) {
+ break 3;
+ }
+ }
+ }
+ }
+
+ foreach ($seen as $entry) {
+ $categoryIds = self::resolveUnifiedCategories(
+ strtolower($entry['value']),
+ $entry['categories'],
+ [strtolower($entry['label'])],
+ );
+ if ($categoryIds === []) {
+ $categoryIds = ['event'];
+ }
+
+ $entry['categories'] = $categoryIds;
+ $entries[] = $entry;
+ }
+
+ usort($entries, static function (array $a, array $b): int {
+ return strcmp($a['label'], $b['label']);
+ });
+
+ return $entries;
+ }
+
+ /**
+ * Keep only consistent UI/Material collections to avoid low-quality previews.
+ *
+ * @param array $collectionInfo
+ */
+ private static function isAllowedIconifyCollection(array $collectionInfo): bool
+ {
+ $category = strtolower(trim((string) ($collectionInfo['category'] ?? '')));
+ if ($category === '') {
+ return false;
+ }
+
+ if (!str_starts_with($category, 'ui') && !str_starts_with($category, 'material')) {
+ return false;
+ }
+
+ foreach (['logo', 'emoji', 'flag', 'archive', 'thematic'] as $blockedCategoryTerm) {
+ if (str_contains($category, $blockedCategoryTerm)) {
+ return false;
+ }
+ }
+
+ foreach (['other', 'mixed grid'] as $blockedUiTerm) {
+ if (str_contains($category, $blockedUiTerm)) {
+ return false;
+ }
+ }
+
+ $height = isset($collectionInfo['height']) ? (int) $collectionInfo['height'] : 0;
+ if (!in_array($height, [16, 20, 24, 32], true)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @return array{icons:string[],collections:array>}|null
+ */
+ private static function fetchIconifySearch(string $query): ?array
+ {
+ $url = self::ICONIFY_SEARCH_URL . '?query=' . rawurlencode($query) . '&limit=' . self::ICONIFY_LIMIT;
+ $response = self::fetchJson($url);
+ if (!is_array($response) || !isset($response['icons']) || !is_array($response['icons'])) {
+ return null;
+ }
+
+ $icons = [];
+ foreach ($response['icons'] as $icon) {
+ $icon = strtolower(trim((string) $icon));
+ if ($icon !== '' && preg_match('/^[a-z0-9-]+:[a-z0-9._-]+$/', $icon) === 1) {
+ $icons[] = $icon;
+ }
+ }
+
+ $collections = [];
+ if (isset($response['collections']) && is_array($response['collections'])) {
+ foreach ($response['collections'] as $prefix => $info) {
+ if (!is_array($info)) {
+ continue;
+ }
+ $collections[strtolower((string) $prefix)] = $info;
+ }
+ }
+
+ return [
+ 'icons' => $icons,
+ 'collections' => $collections,
+ ];
+ }
+
+ /**
+ * @param string[] $categories
+ * @param string[] $tags
+ */
+ private static function isRelevantIcon(string $name, array $categories, array $tags): bool
+ {
+ $haystack = strtolower($name . ' ' . implode(' ', $categories) . ' ' . implode(' ', $tags));
+
+ if (self::isBlockedIconName($haystack)) {
+ return false;
+ }
+
+ foreach (self::EVENT_KEYWORDS as $keyword) {
+ if (str_contains($haystack, $keyword)) {
+ return true;
+ }
+ }
+
+ return count($categories) > 0;
+ }
+
+ private static function isBlockedIconName(string $value): bool
+ {
+ $value = strtolower($value);
+ foreach (self::BLOCKED_KEYWORDS as $keyword) {
+ if (str_contains($value, $keyword)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static function humanizeSlug(string $value): string
+ {
+ $value = trim($value);
+ if ($value === '') {
+ return '';
+ }
+
+ if ($value === 'food-beverage') {
+ return 'Food & Beverage';
+ }
+
+ $parts = explode('-', $value);
+ $parts = array_map(static fn (string $part): string => ucfirst($part), $parts);
+ return implode(' ', $parts);
+ }
+
+ private static function humanizeIconifyName(string $value): string
+ {
+ $value = str_replace(['_', '.'], '-', trim($value));
+ return self::humanizeSlug($value);
+ }
+
+ /**
+ * @param string[] $headers
+ */
+ private static function fetchJson(string $url, array $headers = []): mixed
+ {
+ $headers = array_merge([
+ 'User-Agent: Photobooth/icon-catalog',
+ 'Accept: application/json',
+ ], $headers);
+
+ $context = stream_context_create([
+ 'http' => [
+ 'method' => 'GET',
+ 'header' => implode("\r\n", $headers) . "\r\n",
+ 'timeout' => self::HTTP_TIMEOUT_SECONDS,
+ 'ignore_errors' => true,
+ ],
+ 'ssl' => [
+ 'verify_peer' => true,
+ 'verify_peer_name' => true,
+ ],
+ ]);
+
+ $payload = @file_get_contents($url, false, $context);
+ if ($payload === false) {
+ return null;
+ }
+
+ $decoded = json_decode($payload, true);
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ return null;
+ }
+
+ return $decoded;
+ }
+
+ /**
+ * @return array{generatedAt:string,categories:array,icons:array,search:string}>}|null
+ */
+ private static function readCache(string $cachePath): ?array
+ {
+ if (!is_file($cachePath)) {
+ return null;
+ }
+
+ $raw = @file_get_contents($cachePath);
+ if ($raw === false || trim($raw) === '') {
+ return null;
+ }
+
+ $decoded = json_decode($raw, true);
+ if (!is_array($decoded)) {
+ return null;
+ }
+
+ if (!isset($decoded['categories']) || !is_array($decoded['categories'])) {
+ return null;
+ }
+
+ if (!isset($decoded['icons']) || !is_array($decoded['icons'])) {
+ return null;
+ }
+
+ if (!isset($decoded['generatedAt']) || !is_string($decoded['generatedAt'])) {
+ $decoded['generatedAt'] = gmdate(DATE_ATOM, (int) filemtime($cachePath));
+ }
+
+ if (!isset($decoded['version']) || (int) $decoded['version'] !== self::CACHE_VERSION) {
+ return null;
+ }
+
+ $categories = [];
+ foreach ($decoded['categories'] as $category) {
+ if (!is_array($category)) {
+ return null;
+ }
+
+ $id = trim((string) ($category['id'] ?? ''));
+ $title = trim((string) ($category['title'] ?? ''));
+ $source = trim((string) ($category['source'] ?? ''));
+ if ($id === '' || $title === '' || $source === '') {
+ return null;
+ }
+
+ $categories[] = [
+ 'id' => $id,
+ 'title' => $title,
+ 'source' => $source,
+ ];
+ }
+
+ $icons = [];
+ foreach ($decoded['icons'] as $icon) {
+ if (!is_array($icon)) {
+ return null;
+ }
+
+ $provider = trim((string) ($icon['provider'] ?? ''));
+ $value = trim((string) ($icon['value'] ?? ''));
+ $label = trim((string) ($icon['label'] ?? ''));
+ $search = trim((string) ($icon['search'] ?? ''));
+ if ($provider === '' || $value === '' || $label === '' || $search === '') {
+ return null;
+ }
+
+ $iconCategoriesRaw = $icon['categories'] ?? null;
+ if (!is_array($iconCategoriesRaw)) {
+ return null;
+ }
+
+ $iconCategories = [];
+ foreach ($iconCategoriesRaw as $iconCategory) {
+ $categoryId = trim((string) $iconCategory);
+ if ($categoryId === '') {
+ continue;
+ }
+ $iconCategories[] = $categoryId;
+ }
+
+ $icons[] = [
+ 'provider' => $provider,
+ 'value' => $value,
+ 'label' => $label,
+ 'categories' => $iconCategories,
+ 'search' => $search,
+ ];
+ }
+
+ return [
+ 'generatedAt' => $decoded['generatedAt'],
+ 'categories' => $categories,
+ 'icons' => $icons,
+ ];
+ }
+
+ /**
+ * @param array{generatedAt:string,categories:array,icons:array,search:string}>} $catalog
+ */
+ private static function writeCache(string $cachePath, array $catalog): void
+ {
+ $catalog['version'] = self::CACHE_VERSION;
+ $directory = dirname($cachePath);
+ if (!is_dir($directory)) {
+ @mkdir($directory, 0775, true);
+ }
+
+ $json = json_encode($catalog, JSON_UNESCAPED_SLASHES);
+ if ($json === false) {
+ return;
+ }
+
+ @file_put_contents($cachePath, $json, LOCK_EX);
+ }
+
+ private static function isCacheFresh(string $cachePath): bool
+ {
+ if (!is_file($cachePath)) {
+ return false;
+ }
+
+ $mtime = (int) filemtime($cachePath);
+ return $mtime > 0 && (time() - $mtime) < self::CACHE_TTL_SECONDS;
+ }
+
+ /**
+ * @return array{generatedAt:string,categories:array,icons:array,search:string}>}
+ */
+ private static function fallbackCatalog(): array
+ {
+ return [
+ 'generatedAt' => gmdate(DATE_ATOM),
+ 'categories' => self::buildBaseCategories(),
+ 'icons' => [
+ [
+ 'provider' => 'lucide',
+ 'value' => 'camera',
+ 'label' => 'Camera',
+ 'categories' => ['photo', 'event'],
+ 'search' => 'camera photo event',
+ ],
+ [
+ 'provider' => 'lucide',
+ 'value' => 'heart',
+ 'label' => 'Heart',
+ 'categories' => ['love', 'event'],
+ 'search' => 'heart love event',
+ ],
+ [
+ 'provider' => 'lucide',
+ 'value' => 'cake',
+ 'label' => 'Cake',
+ 'categories' => ['food', 'event'],
+ 'search' => 'cake party event',
+ ],
+ [
+ 'provider' => 'lucide',
+ 'value' => 'sun',
+ 'label' => 'Sun',
+ 'categories' => ['nature', 'event'],
+ 'search' => 'sun weather event',
+ ],
+ [
+ 'provider' => 'lucide',
+ 'value' => 'cog',
+ 'label' => 'Cog',
+ 'categories' => ['tools', 'event'],
+ 'search' => 'cog settings event',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @param array{generatedAt:string,categories:array,icons:array,search:string}>} $catalog
+ *
+ * @return array{generatedAt:string,categories:array,icons:array,search:string}>}
+ */
+ private static function withCustomImages(array $catalog): array
+ {
+ $customEntries = self::getCustomImageEntries();
+ if ($customEntries === []) {
+ return $catalog;
+ }
+
+ $seenValues = [];
+ foreach ($catalog['icons'] as $icon) {
+ $seenValues[$icon['value']] = true;
+ }
+
+ foreach ($customEntries as $entry) {
+ if (!isset($seenValues[$entry['value']])) {
+ $catalog['icons'][] = $entry;
+ $seenValues[$entry['value']] = true;
+ }
+ }
+
+ usort($catalog['icons'], static function (array $a, array $b): int {
+ if ($a['provider'] !== $b['provider']) {
+ return strcmp($a['provider'], $b['provider']);
+ }
+ return strcmp($a['label'], $b['label']);
+ });
+
+ return $catalog;
+ }
+
+ /**
+ * @return array,search:string}>
+ */
+ private static function getCustomImageEntries(): array
+ {
+ $directory = self::getCustomImageDirectoryAbsolute();
+ if (!is_dir($directory)) {
+ return [];
+ }
+
+ $entries = [];
+ $files = scandir($directory);
+ if (!is_array($files)) {
+ return [];
+ }
+
+ $relativeDirectory = self::getCustomImageDirectoryRelative();
+ foreach ($files as $file) {
+ if ($file === '.' || $file === '..') {
+ continue;
+ }
+
+ $absolutePath = $directory . DIRECTORY_SEPARATOR . $file;
+ if (!is_file($absolutePath)) {
+ continue;
+ }
+
+ $relativePath = $relativeDirectory . '/' . $file;
+ $entry = self::buildCustomImageEntry($relativePath);
+ if ($entry !== null) {
+ $entries[] = $entry;
+ }
+ }
+
+ usort($entries, static function (array $a, array $b): int {
+ return strcmp($a['label'], $b['label']);
+ });
+
+ return $entries;
+ }
+
+ /**
+ * @param string[] $lucideCategories
+ * @param string[] $tags
+ *
+ * @return string[]
+ */
+ private static function resolveUnifiedCategories(string $name, array $lucideCategories, array $tags): array
+ {
+ $categoryMap = [];
+
+ foreach ($lucideCategories as $category) {
+ $category = strtolower(trim((string) $category));
+ if ($category === '') {
+ continue;
+ }
+
+ if (isset(self::LUCIDE_CATEGORY_TO_GROUPS[$category])) {
+ foreach (self::LUCIDE_CATEGORY_TO_GROUPS[$category] as $mappedCategory) {
+ self::addCategoryIfValid($categoryMap, $mappedCategory);
+ }
+ }
+ }
+
+ $haystack = strtolower($name . ' ' . implode(' ', $lucideCategories) . ' ' . implode(' ', $tags));
+ foreach (self::CATEGORY_KEYWORDS as $categoryId => $keywords) {
+ foreach ($keywords as $keyword) {
+ if (str_contains($haystack, $keyword)) {
+ self::addCategoryIfValid($categoryMap, $categoryId);
+ break;
+ }
+ }
+ }
+
+ if ($categoryMap === []) {
+ self::addCategoryIfValid($categoryMap, 'event');
+ }
+
+ return array_values(array_map(static fn (string|int $categoryId): string => (string) $categoryId, array_keys($categoryMap)));
+ }
+
+ /**
+ * @param array $categoryMap
+ */
+ private static function addCategoryIfValid(array &$categoryMap, string $categoryId): void
+ {
+ if ($categoryId === 'all') {
+ return;
+ }
+
+ if (!isset(self::CATEGORY_DEFINITIONS[$categoryId])) {
+ return;
+ }
+
+ $categoryMap[$categoryId] = true;
+ }
+
+ /**
+ * @return array
+ */
+ private static function buildBaseCategories(): array
+ {
+ $result = [];
+ foreach (self::CATEGORY_DEFINITIONS as $id => $definition) {
+ $result[] = [
+ 'id' => $id,
+ 'title' => $definition['title'],
+ 'source' => $definition['source'],
+ ];
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Utility/EventSymbolUtility.php b/src/Utility/EventSymbolUtility.php
new file mode 100644
index 000000000..a5e383ef3
--- /dev/null
+++ b/src/Utility/EventSymbolUtility.php
@@ -0,0 +1,392 @@
+
+ */
+ private const LEGACY_FONT_AWESOME_ICONS = [
+ 'fa-camera' => 'Camera',
+ 'fa-camera-retro' => 'Camera Retro',
+ 'fa-birthday-cake' => 'Birthday Cake',
+ 'fa-gift' => 'Gift',
+ 'fa-tree' => 'Tree',
+ 'fa-snowflake' => 'Snowflake',
+ 'fa-heart-o' => 'Heart (Outline)',
+ 'fa-regular fa-heart' => 'Heart',
+ 'fa-solid fa-heart' => 'Heart (Filled)',
+ 'fa-solid fa-heart-pulse' => 'Heartbeat',
+ 'fa-solid fa-sun' => 'Sun',
+ 'fa-brands fa-apple' => 'Apple',
+ 'fa-anchor' => 'Anchor',
+ 'fa-light fa-champagne-glasses' => 'Champagne Glasses',
+ 'fa-gears' => 'Gears',
+ 'fa-users' => 'People',
+ ];
+
+ /**
+ * Lucide fallbacks used only for dual rendering contexts.
+ *
+ * @var array
+ */
+ private const LEGACY_TO_LUCIDE_MAP = [
+ 'fa-camera' => 'camera',
+ 'fa-camera-retro' => 'camera',
+ 'fa-birthday-cake' => 'cake',
+ 'fa-gift' => 'gift',
+ 'fa-tree' => 'tree-pine',
+ 'fa-snowflake' => 'snowflake',
+ 'fa-heart-o' => 'heart',
+ 'fa-regular fa-heart' => 'heart',
+ 'fa-solid fa-heart' => 'heart',
+ 'fa-solid fa-heart-pulse' => 'heart-pulse',
+ 'fa-solid fa-sun' => 'sun',
+ 'fa-brands fa-apple' => 'apple',
+ 'fa-anchor' => 'anchor',
+ 'fa-light fa-champagne-glasses' => 'party-popper',
+ 'fa-champagne-glasses' => 'party-popper',
+ 'fa-gears' => 'cog',
+ 'fa-cogs' => 'cog',
+ 'fa-users' => 'users',
+ ];
+
+ private const CUSTOM_IMAGE_PREFIX = 'image:';
+ private const CUSTOM_IMAGE_DIRECTORY = 'private/images/event-symbols/';
+
+ /**
+ * @var string[]
+ */
+ private const CUSTOM_IMAGE_EXTENSIONS = ['svg', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'avif'];
+
+ public static function normalize(?string $value): string
+ {
+ $value = trim((string)$value);
+ if ($value === '') {
+ return 'camera';
+ }
+
+ $lower = strtolower($value);
+
+ if (str_starts_with($lower, self::CUSTOM_IMAGE_PREFIX) || self::looksLikeCustomImagePath($value)) {
+ $customImage = self::normalizeCustomImageValue($value);
+ return $customImage !== '' ? $customImage : 'camera';
+ }
+
+ if (str_starts_with($lower, 'lucide:')) {
+ $value = substr($value, 7);
+ $lower = strtolower($value);
+ } elseif (str_starts_with($lower, 'fa:')) {
+ $value = substr($value, 3);
+ $lower = strtolower($value);
+ } elseif (str_starts_with($lower, 'iconify:')) {
+ $value = substr($value, 8);
+ $lower = strtolower($value);
+ }
+
+ if (self::isFontAwesomeSymbol($lower)) {
+ $faClasses = self::sanitizeFontAwesomeClasses($lower);
+ return $faClasses !== '' ? $faClasses : 'fa-camera';
+ }
+
+ if (self::isIconifySymbol($lower)) {
+ $iconify = self::normalizeIconifyName($lower);
+ return $iconify !== '' ? 'iconify:' . $iconify : 'iconify:mdi:camera';
+ }
+
+ $lucide = self::normalizeLucideName($value);
+ return $lucide !== '' ? $lucide : 'camera';
+ }
+
+ public static function isFontAwesomeSymbol(?string $value): bool
+ {
+ $value = strtolower(trim((string)$value));
+ if ($value === '') {
+ return false;
+ }
+
+ if (isset(self::LEGACY_FONT_AWESOME_ICONS[$value])) {
+ return true;
+ }
+
+ return preg_match('/(^|\\s)fa($|\\s)|fa-[a-z0-9-]+/', $value) === 1;
+ }
+
+ public static function sanitizeFontAwesomeClasses(?string $value): string
+ {
+ $value = strtolower(trim((string)$value));
+ if ($value === '') {
+ return '';
+ }
+
+ $tokens = preg_split('/\\s+/', $value) ?: [];
+ $classes = [];
+ foreach ($tokens as $token) {
+ $token = trim($token);
+ if ($token === '' || (!str_starts_with($token, 'fa-') && $token !== 'fa')) {
+ continue;
+ }
+ $classes[$token] = true;
+ }
+
+ if (!isset($classes['fa']) && count($classes) > 0) {
+ $classes = ['fa' => true] + $classes;
+ }
+
+ // Must contain at least one icon class, not only style wrappers.
+ $hasIconClass = false;
+ foreach (array_keys($classes) as $className) {
+ if (str_starts_with($className, 'fa-') && !in_array($className, ['fa-solid', 'fa-regular', 'fa-brands', 'fa-light', 'fa-thin', 'fa-sharp', 'fa-classic'], true)) {
+ $hasIconClass = true;
+ break;
+ }
+ }
+
+ if (!$hasIconClass) {
+ return '';
+ }
+
+ return implode(' ', array_keys($classes));
+ }
+
+ public static function normalizeLucideName(?string $value): string
+ {
+ $value = strtolower(trim((string)$value));
+ $value = preg_replace('/[^a-z0-9-]+/', '-', $value);
+ $value = preg_replace('/-+/', '-', (string)$value);
+ return trim((string)$value, '-');
+ }
+
+ public static function isIconifySymbol(?string $value): bool
+ {
+ $value = strtolower(trim((string)$value));
+ if ($value === '') {
+ return false;
+ }
+
+ if (str_starts_with($value, 'iconify:')) {
+ $value = substr($value, 8);
+ }
+
+ return preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*:[a-z0-9][a-z0-9._-]*$/', $value) === 1;
+ }
+
+ public static function normalizeIconifyName(?string $value): string
+ {
+ $value = strtolower(trim((string)$value));
+ if ($value === '') {
+ return '';
+ }
+
+ if (str_starts_with($value, 'iconify:')) {
+ $value = substr($value, 8);
+ }
+
+ if (strpos($value, ':') === false) {
+ return '';
+ }
+
+ [$prefix, $icon] = explode(':', $value, 2);
+ $prefix = preg_replace('/[^a-z0-9-]+/', '', $prefix);
+ $prefix = preg_replace('/-+/', '-', (string)$prefix);
+ $prefix = trim((string)$prefix, '-');
+
+ $icon = preg_replace('/[^a-z0-9._-]+/', '-', $icon);
+ $icon = preg_replace('/-+/', '-', (string)$icon);
+ $icon = trim((string)$icon, '-._');
+
+ if ($prefix === '' || $icon === '') {
+ return '';
+ }
+
+ return $prefix . ':' . $icon;
+ }
+
+ public static function normalizeCustomImageValue(?string $value): string
+ {
+ $value = trim((string) $value);
+ if ($value === '') {
+ return '';
+ }
+
+ $lower = strtolower($value);
+ if (str_starts_with($lower, self::CUSTOM_IMAGE_PREFIX)) {
+ $value = substr($value, strlen(self::CUSTOM_IMAGE_PREFIX));
+ }
+
+ $value = str_replace('\\', '/', $value);
+ $value = ltrim($value, '/');
+ $value = preg_replace('#/+#', '/', $value);
+ $value = (string) $value;
+
+ if ($value === '' || str_contains($value, "\0") || str_contains($value, '..')) {
+ return '';
+ }
+
+ if (preg_match('/^[A-Za-z0-9._\/-]+$/', $value) !== 1) {
+ return '';
+ }
+
+ if (!str_starts_with(strtolower($value), self::CUSTOM_IMAGE_DIRECTORY)) {
+ return '';
+ }
+
+ $extension = strtolower(pathinfo($value, PATHINFO_EXTENSION));
+ if ($extension === '' || !in_array($extension, self::CUSTOM_IMAGE_EXTENSIONS, true)) {
+ return '';
+ }
+
+ return self::CUSTOM_IMAGE_PREFIX . $value;
+ }
+
+ public static function isCustomImageSymbol(?string $value): bool
+ {
+ if ($value === null || trim($value) === '') {
+ return false;
+ }
+
+ if (self::normalizeCustomImageValue($value) !== '') {
+ return true;
+ }
+
+ $normalized = self::normalize($value);
+ return str_starts_with(strtolower($normalized), self::CUSTOM_IMAGE_PREFIX);
+ }
+
+ public static function getCustomImagePath(?string $value): string
+ {
+ $normalizedCustom = self::normalizeCustomImageValue($value);
+ if ($normalizedCustom === '') {
+ $normalizedCustom = self::normalize($value);
+ }
+
+ if (!str_starts_with(strtolower($normalizedCustom), self::CUSTOM_IMAGE_PREFIX)) {
+ return '';
+ }
+
+ return substr($normalizedCustom, strlen(self::CUSTOM_IMAGE_PREFIX));
+ }
+
+ public static function getCustomImagePublicPath(?string $value): string
+ {
+ $path = self::getCustomImagePath($value);
+ if ($path === '') {
+ return '';
+ }
+
+ return PathUtility::getPublicPath($path);
+ }
+
+ public static function getCustomImageDirectory(): string
+ {
+ return rtrim(self::CUSTOM_IMAGE_DIRECTORY, '/');
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAllowedCustomImageExtensions(): array
+ {
+ return self::CUSTOM_IMAGE_EXTENSIONS;
+ }
+
+ public static function getSymbolType(?string $value): string
+ {
+ $normalized = self::normalize($value);
+
+ if (self::isCustomImageSymbol($normalized)) {
+ return 'image';
+ }
+
+ if (self::isFontAwesomeSymbol($normalized)) {
+ return 'fa';
+ }
+
+ $normalized = strtolower(trim($normalized));
+ if (str_starts_with($normalized, 'iconify:') || self::isIconifySymbol($normalized)) {
+ return 'iconify';
+ }
+
+ return 'lucide';
+ }
+
+ public static function getLucideFallback(?string $value): string
+ {
+ $normalized = self::normalize($value);
+ $lower = strtolower($normalized);
+
+ if (self::isCustomImageSymbol($lower)) {
+ return 'camera';
+ }
+
+ if (!self::isFontAwesomeSymbol($lower)) {
+ return $lower;
+ }
+
+ if (isset(self::LEGACY_TO_LUCIDE_MAP[$lower])) {
+ return self::LEGACY_TO_LUCIDE_MAP[$lower];
+ }
+
+ $tokens = preg_split('/\\s+/', $lower);
+ if (!is_array($tokens)) {
+ return 'camera';
+ }
+
+ foreach ($tokens as $token) {
+ if (isset(self::LEGACY_TO_LUCIDE_MAP[$token])) {
+ return self::LEGACY_TO_LUCIDE_MAP[$token];
+ }
+ }
+
+ return 'camera';
+ }
+
+ public static function getFontAwesomeClasses(?string $value): string
+ {
+ $normalized = self::normalize($value);
+ return self::sanitizeFontAwesomeClasses($normalized);
+ }
+
+ public static function getIconifyName(?string $value): string
+ {
+ $normalized = self::normalize($value);
+ $lower = strtolower(trim($normalized));
+
+ if (str_starts_with($lower, 'iconify:')) {
+ $name = self::normalizeIconifyName(substr($lower, 8));
+ return $name !== '' ? $name : 'mdi:camera';
+ }
+
+ if (self::isIconifySymbol($lower)) {
+ $name = self::normalizeIconifyName($lower);
+ return $name !== '' ? $name : 'mdi:camera';
+ }
+
+ return 'mdi:camera';
+ }
+
+ /**
+ * @return array
+ */
+ public static function getLegacyIconList(): array
+ {
+ $result = [];
+ foreach (self::LEGACY_FONT_AWESOME_ICONS as $value => $label) {
+ $result[] = [
+ 'value' => $value,
+ 'label' => $label,
+ ];
+ }
+
+ return $result;
+ }
+
+ private static function looksLikeCustomImagePath(string $value): bool
+ {
+ $normalized = strtolower(str_replace('\\', '/', ltrim(trim($value), '/')));
+ return str_starts_with($normalized, self::CUSTOM_IMAGE_DIRECTORY);
+ }
+}
diff --git a/src/Utility/LucideIconUtility.php b/src/Utility/LucideIconUtility.php
new file mode 100644
index 000000000..49a69e0fa
--- /dev/null
+++ b/src/Utility/LucideIconUtility.php
@@ -0,0 +1,67 @@
+getUrl('api/settings.php') . '">';
+echo '';
+echo '';
echo '';
if ($remoteBuzzer) {
diff --git a/template/components/stage.start.php b/template/components/stage.start.php
index c6109762d..143350d35 100644
--- a/template/components/stage.start.php
+++ b/template/components/stage.start.php
@@ -1,7 +1,16 @@
EventSymbolUtility::getFontAwesomeClasses($config['event']['symbol'] ?? ''),
+ 'iconify' => EventSymbolUtility::getIconifyName($config['event']['symbol'] ?? ''),
+ 'image' => EventSymbolUtility::getCustomImagePublicPath($config['event']['symbol'] ?? ''),
+ default => EventSymbolUtility::getLucideFallback($config['event']['symbol'] ?? ''),
+};
+
$startpageTextPosition = StartpageTextPosition::resolve(
$config['ui']['startpage_text_position'] ?? null,
$config['logo']['position'] ?? null,
@@ -19,7 +28,15 @@
= $config['event']['textLeft'] ?>
-
+
+
+
+
+
+
+
+
+
= $config['event']['textRight'] ?>