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 . '
+ + + +
+
+ + + +
+ '; + } + 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 @@

- + + + + + + + + +