Neue Branding-Page und Gäste-PWA reagiert nun auf Branding-Einstellungen vom event-admin. Implemented local Google Fonts pipeline and admin UI selects for branding and invites.

- Added fonts:sync-google command (uses GOOGLE_FONTS_API_KEY, generates /public/fonts/google files, manifest, CSS, cache flush) and
    exposed manifest via new GET /api/v1/tenant/fonts endpoint with fallbacks for existing local fonts.
  - Imported generated fonts CSS, added API client + font loader hook, and wired branding page font fields to searchable selects (with
    custom override) that auto-load selected fonts.
  - Invites layout editor now offers font selection per element with runtime font loading for previews/export alignment.
  - New tests cover font sync command and font manifest API.

  Tests run: php artisan test --filter=Fonts --testsuite=Feature.
  Note: repository already has other modified files (e.g., EventPublicController, SettingsStoreRequest, guest components, etc.); left
  untouched. Run php artisan fonts:sync-google after setting the API key to populate /public/fonts/google.
This commit is contained in:
Codex Agent
2025-11-25 19:31:52 +01:00
parent 4d31eb4d42
commit 9bde8f3f32
38 changed files with 2420 additions and 104 deletions

View File

@@ -847,7 +847,7 @@ class EventPublicController extends BaseController
}
// Common relative paths stored in DB (e.g. 'photos/...', 'thumbnails/...', 'events/...')
if (str_starts_with($path, 'photos/') || str_starts_with($path, 'thumbnails/') || str_starts_with($path, 'events/')) {
if (str_starts_with($path, 'photos/') || str_starts_with($path, 'thumbnails/') || str_starts_with($path, 'events/') || str_starts_with($path, 'branding/')) {
return Storage::url($path);
}
@@ -895,42 +895,232 @@ class EventPublicController extends BaseController
private function buildGalleryBranding(Event $event): array
{
$defaultPrimary = '#f43f5e';
$defaultSecondary = '#fb7185';
$defaultBackground = '#ffffff';
return $this->resolveBrandingPayload($event);
}
$event->loadMissing('eventPackage.package', 'tenant');
private function normalizeHexColor(?string $value, string $fallback): string
{
if (is_string($value)) {
$trimmed = trim($value);
if (preg_match('/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/', $trimmed) === 1) {
return $trimmed;
}
}
return $fallback;
}
/**
* @param array<int, array|mixed> $sources
*/
private function firstStringFromSources(array $sources, array $keys): ?string
{
foreach ($sources as $source) {
foreach ($keys as $key) {
$value = Arr::get($source ?? [], $key);
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
}
return null;
}
/**
* @param array<int, array|mixed> $sources
*/
private function firstNumberFromSources(array $sources, array $keys): ?float
{
foreach ($sources as $source) {
foreach ($keys as $key) {
$value = Arr::get($source ?? [], $key);
if (is_numeric($value)) {
return (float) $value;
}
}
}
return null;
}
/**
* @return array{
* primary_color: string,
* secondary_color: string,
* background_color: string,
* surface_color: string,
* font_family: ?string,
* heading_font: ?string,
* body_font: ?string,
* font_size: string,
* logo_url: ?string,
* logo_mode: string,
* logo_value: ?string,
* logo_position: string,
* logo_size: string,
* button_style: string,
* button_radius: int,
* button_primary_color: string,
* button_secondary_color: string,
* link_color: string,
* mode: string,
* use_default_branding: bool,
* palette: array{primary:string, secondary:string, background:string, surface:string},
* typography: array{heading:?string, body:?string, size:string},
* logo: array{mode:string, value:?string, position:string, size:string},
* buttons: array{style:string, radius:int, primary:string, secondary:string, link_color:string},
* icon: ?string
* }
*/
private function resolveBrandingPayload(Event $event): array
{
$defaults = [
'primary' => '#f43f5e',
'secondary' => '#fb7185',
'background' => '#ffffff',
'surface' => '#ffffff',
'font' => null,
'size' => 'm',
'logo_position' => 'left',
'logo_size' => 'm',
'button_style' => 'filled',
'button_radius' => 12,
'mode' => 'auto',
];
$event->loadMissing('eventPackage.package', 'tenant', 'eventType');
$brandingAllowed = $this->determineBrandingAllowed($event);
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
return [
'primary_color' => Arr::get($eventBranding, 'primary_color')
?? Arr::get($tenantBranding, 'primary_color')
?? $defaultPrimary,
'secondary_color' => Arr::get($eventBranding, 'secondary_color')
?? Arr::get($tenantBranding, 'secondary_color')
?? $defaultSecondary,
'background_color' => Arr::get($eventBranding, 'background_color')
?? Arr::get($tenantBranding, 'background_color')
?? $defaultBackground,
];
}
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
$sources = $brandingAllowed
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
: [[]];
private function resolveFontFamily(Event $event): ?string
{
$fontFamily = Arr::get($event->settings, 'branding.font_family')
?? Arr::get($event->tenant?->settings, 'branding.font_family');
$primary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
$defaults['primary']
);
$secondary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.secondary', 'secondary_color']),
$defaults['secondary']
);
$background = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.background', 'background_color']),
$defaults['background']
);
$surface = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.surface', 'surface_color']),
$background ?: $defaults['surface']
);
if (! is_string($fontFamily)) {
return null;
$headingFont = $this->firstStringFromSources($sources, ['typography.heading', 'heading_font', 'font_family']);
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
$fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size'];
$logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
$logoMode = null;
}
$logoPosition = $this->firstStringFromSources($sources, ['logo.position', 'logo_position']) ?? $defaults['logo_position'];
if (! in_array($logoPosition, ['left', 'right', 'center'], true)) {
$logoPosition = $defaults['logo_position'];
}
$logoSize = $this->firstStringFromSources($sources, ['logo.size', 'logo_size']) ?? $defaults['logo_size'];
if (! in_array($logoSize, ['s', 'm', 'l'], true)) {
$logoSize = $defaults['logo_size'];
}
$normalized = strtolower(trim($fontFamily));
$defaultInter = strtolower('Inter, sans-serif');
$logoRawValue = $this->firstStringFromSources($sources, ['logo.value', 'logo_url', 'icon'])
?? ($event->eventType?->icon ?? null);
if (! $logoMode) {
$logoMode = $logoRawValue && (preg_match('/^https?:\/\//', $logoRawValue) === 1
|| str_starts_with($logoRawValue, '/storage/')
|| str_starts_with($logoRawValue, 'storage/'))
? 'upload'
: 'emoticon';
}
return $normalized === $defaultInter ? null : $fontFamily;
$logoValue = $logoMode === 'upload' ? $this->toPublicUrl($logoRawValue) : $logoRawValue;
$buttonStyle = $this->firstStringFromSources($sources, ['buttons.style', 'button_style']) ?? $defaults['button_style'];
if (! in_array($buttonStyle, ['filled', 'outline'], true)) {
$buttonStyle = $defaults['button_style'];
}
$buttonRadius = (int) ($this->firstNumberFromSources($sources, ['buttons.radius', 'button_radius']) ?? $defaults['button_radius']);
$buttonRadius = $buttonRadius > 0 ? $buttonRadius : $defaults['button_radius'];
$buttonPrimary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['buttons.primary', 'button_primary_color']),
$primary
);
$buttonSecondary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['buttons.secondary', 'button_secondary_color']),
$secondary
);
$linkColor = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['buttons.link_color', 'link_color']),
$secondary
);
$mode = $this->firstStringFromSources($sources, ['mode']) ?? $defaults['mode'];
if (! in_array($mode, ['light', 'dark', 'auto'], true)) {
$mode = $defaults['mode'];
}
return [
'primary_color' => $primary,
'secondary_color' => $secondary,
'background_color' => $background,
'surface_color' => $surface,
'font_family' => $bodyFont,
'heading_font' => $headingFont,
'body_font' => $bodyFont,
'font_size' => $fontSize,
'logo_url' => $logoMode === 'upload' ? $logoValue : null,
'logo_mode' => $logoMode,
'logo_value' => $logoValue,
'logo_position' => $logoPosition,
'logo_size' => $logoSize,
'button_style' => $buttonStyle,
'button_radius' => $buttonRadius,
'button_primary_color' => $buttonPrimary,
'button_secondary_color' => $buttonSecondary,
'link_color' => $linkColor,
'mode' => $mode,
'use_default_branding' => $useDefault,
'palette' => [
'primary' => $primary,
'secondary' => $secondary,
'background' => $background,
'surface' => $surface,
],
'typography' => [
'heading' => $headingFont,
'body' => $bodyFont,
'size' => $fontSize,
],
'logo' => [
'mode' => $logoMode,
'value' => $logoValue,
'position' => $logoPosition,
'size' => $logoSize,
],
'buttons' => [
'style' => $buttonStyle,
'radius' => $buttonRadius,
'primary' => $buttonPrimary,
'secondary' => $buttonSecondary,
'link_color' => $linkColor,
],
'icon' => $logoMode === 'emoticon' ? $logoValue : ($event->eventType?->icon ?? null),
];
}
private function encodeGalleryCursor(Photo $photo): string
@@ -1393,12 +1583,6 @@ class EventPublicController extends BaseController
];
$branding = $this->buildGalleryBranding($event);
$fontFamily = $this->resolveFontFamily($event);
$brandingAllowed = $this->determineBrandingAllowed($event);
$logoUrl = $brandingAllowed
? (Arr::get($event->settings, 'branding.logo_url')
?? Arr::get($event->tenant?->settings, 'branding.logo_url'))
: null;
if ($joinToken) {
$this->joinTokenService->incrementUsage($joinToken);
@@ -1414,13 +1598,7 @@ class EventPublicController extends BaseController
'type' => $eventTypeData,
'join_token' => $joinToken?->token,
'photobooth_enabled' => (bool) $event->photobooth_enabled,
'branding' => [
'primary_color' => $branding['primary_color'],
'secondary_color' => $branding['secondary_color'],
'background_color' => $branding['background_color'],
'font_family' => $fontFamily,
'logo_url' => $this->toPublicUrl($logoUrl),
],
'branding' => $branding,
])->header('Cache-Control', 'no-store');
}