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

@@ -0,0 +1,255 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class SyncGoogleFonts extends Command
{
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)}';
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
private const API_ENDPOINT = 'https://www.googleapis.com/webfonts/v1/webfonts';
public function handle(): int
{
$apiKey = config('services.google_fonts.key');
if (! $apiKey) {
$this->error('GOOGLE_FONTS_API_KEY is missing in the environment.');
return self::FAILURE;
}
$count = max(1, (int) $this->option('count'));
$weights = $this->prepareWeights($this->option('weights'));
$includeItalic = (bool) $this->option('italic');
$force = (bool) $this->option('force');
$pathOption = $this->option('path');
$basePath = $pathOption
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
: public_path('fonts/google');
$this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
$response = Http::retry(2, 200)
->timeout(30)
->get(self::API_ENDPOINT, [
'key' => $apiKey,
'sort' => 'popularity',
]);
if (! $response->ok()) {
$this->error(sprintf('Google Fonts API failed: %s', $response->body()));
return self::FAILURE;
}
$items = Arr::get($response->json(), 'items', []);
if (! is_array($items) || ! count($items)) {
$this->warn('Google Fonts API returned no items.');
return self::SUCCESS;
}
$selected = array_slice($items, 0, $count);
$manifestFonts = [];
$filesystem = new Filesystem();
File::ensureDirectoryExists($basePath);
foreach ($selected as $index => $font) {
if (! is_array($font) || ! isset($font['family'])) {
continue;
}
$family = (string) $font['family'];
$slug = Str::slug($family);
$familyDir = $basePath.DIRECTORY_SEPARATOR.$slug;
File::ensureDirectoryExists($familyDir);
$variantMap = $this->buildVariantMap($font, $weights, $includeItalic);
if (! count($variantMap)) {
$this->warn("Skipping {$family} (no matching variants)");
continue;
}
$variants = [];
foreach ($variantMap as $variantKey => $fileUrl) {
$style = str_contains($variantKey, 'italic') ? 'italic' : 'normal';
$weight = (int) preg_replace('/[^0-9]/', '', $variantKey) ?: 400;
$extension = pathinfo(parse_url($fileUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ?: 'woff2';
$filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension);
$targetPath = $familyDir.DIRECTORY_SEPARATOR.$filename;
if (! $force && $filesystem->exists($targetPath)) {
$this->line("{$family} {$variantKey} already exists");
} else {
$this->line("↓ Downloading {$family} {$variantKey}");
$fileResponse = Http::retry(2, 200)->timeout(30)->get($fileUrl);
if (! $fileResponse->ok()) {
$this->warn(" Skipped {$family} {$variantKey} (download failed)");
continue;
}
$filesystem->put($targetPath, $fileResponse->body());
}
$relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename);
$variants[] = [
'variant' => $variantKey,
'weight' => $weight,
'style' => $style,
'url' => $relativePath,
];
}
if (! count($variants)) {
continue;
}
$manifestFonts[] = [
'family' => $family,
'slug' => $slug,
'category' => $font['category'] ?? null,
'variants' => $variants,
];
}
$this->pruneStaleFamilies($basePath, $manifestFonts);
$this->writeManifest($basePath, $manifestFonts);
$this->writeCss($basePath, $manifestFonts);
Cache::forget('fonts:manifest');
$this->info(sprintf('Synced %d font families to %s', count($manifestFonts), $basePath));
return self::SUCCESS;
}
/**
* @return array<int, int>
*/
private function prepareWeights(string $weights): array
{
$parts = array_filter(array_map('trim', explode(',', $weights)));
$numeric = array_map(fn ($weight) => (int) $weight, $parts);
$numeric = array_filter($numeric, fn ($weight) => $weight >= 100 && $weight <= 900);
return $numeric ? array_values(array_unique($numeric)) : [400, 700];
}
/**
* @param array<string, mixed> $font
* @return array<string, string>
*/
private function buildVariantMap(array $font, array $weights, bool $includeItalic): array
{
$files = $font['files'] ?? [];
if (! is_array($files)) {
return [];
}
$variants = [];
foreach ($weights as $weight) {
$normalKey = $weight === 400 ? 'regular' : (string) $weight;
$italicKey = $weight === 400 ? 'italic' : $weight.'italic';
if (isset($files[$normalKey])) {
$variants[$normalKey] = (string) $files[$normalKey];
}
if ($includeItalic && isset($files[$italicKey])) {
$variants[$italicKey] = (string) $files[$italicKey];
}
}
// Fallback: if no requested weights present, try any provided
if (! count($variants)) {
foreach ($files as $key => $url) {
if (is_string($key) && is_string($url)) {
$variants[$key] = $url;
}
}
}
return $variants;
}
private function pruneStaleFamilies(string $basePath, array $manifestFonts): void
{
$keep = collect($manifestFonts)->pluck('slug')->filter()->all();
$directories = File::directories($basePath);
foreach ($directories as $dir) {
$name = basename($dir);
if (! in_array($name, $keep, true)) {
File::deleteDirectory($dir);
$this->line("🗑 Removed stale font directory {$name}");
}
}
}
private function writeManifest(string $basePath, array $fonts): void
{
$manifest = [
'generated_at' => now()->toIso8601String(),
'source' => 'google-webfonts',
'count' => count($fonts),
'fonts' => $fonts,
];
File::put($basePath.'/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
private function writeCss(string $basePath, array $fonts): void
{
$lines = [
'/* Auto-generated by fonts:sync-google */',
];
foreach ($fonts as $font) {
$family = $font['family'] ?? null;
$variants = $font['variants'] ?? [];
if (! $family || ! is_array($variants)) {
continue;
}
foreach ($variants as $variant) {
if (! isset($variant['url'])) {
continue;
}
$lines[] = sprintf(
"@font-face {\n font-family: '%s';\n font-style: %s;\n font-weight: %s;\n font-display: swap;\n src: url('%s') format('%s');\n}\n",
$family,
$variant['style'] ?? 'normal',
$variant['weight'] ?? 400,
$variant['url'],
$this->guessFormat((string) ($variant['url'] ?? ''))
);
}
}
File::put($basePath.'/fonts.css', implode("\n", $lines));
}
private function guessFormat(string $url): string
{
$extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
return match ($extension) {
'ttf' => 'truetype',
'otf' => 'opentype',
'woff' => 'woff',
default => 'woff2',
};
}
}