events werden nun erfolgreich gespeichert, branding wird nun erfolgreich gespeichert, emotionen können nun angelegt werden. Task Ansicht im Event admin verbessert, Buttons in FAB umgewandelt und vereinheitlicht. Teilen-Link Guest PWA schicker gemacht, SynGoogleFonts ausgebaut (mit Einzel-Family-Download).
This commit is contained in:
@@ -6,13 +6,13 @@ 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\Facades\Http;
|
||||
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 $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)} {--family= : Download a single family by name (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--dry-run : Show what would be downloaded without writing files}';
|
||||
|
||||
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
||||
|
||||
@@ -32,13 +32,28 @@ class SyncGoogleFonts extends Command
|
||||
$weights = $this->prepareWeights($this->option('weights'));
|
||||
$includeItalic = (bool) $this->option('italic');
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$familyOption = $this->normalizeFamilyOption($this->option('family'));
|
||||
$categories = $this->prepareCategories($this->option('category'));
|
||||
|
||||
$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'));
|
||||
if ($familyOption) {
|
||||
$this->info(sprintf('Fetching Google Font family "%s" (weights: %s, italic: %s)...', $familyOption, implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
||||
} else {
|
||||
$this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
||||
}
|
||||
|
||||
if (count($categories)) {
|
||||
$this->line('Category filter: '.implode(', ', $categories));
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('Dry run enabled: no files will be written.');
|
||||
}
|
||||
|
||||
$response = Http::retry(2, 200)
|
||||
->timeout(30)
|
||||
@@ -60,10 +75,27 @@ class SyncGoogleFonts extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$selected = array_slice($items, 0, $count);
|
||||
$items = $this->filterFonts($items, $familyOption, $categories);
|
||||
|
||||
if ($familyOption && ! count($items)) {
|
||||
$this->error(sprintf('Font family "%s" was not found.', $familyOption));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! count($items)) {
|
||||
$this->warn('No fonts matched the provided filters.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$selected = $familyOption ? $items : array_slice($items, 0, $count);
|
||||
$manifestFonts = [];
|
||||
$filesystem = new Filesystem();
|
||||
File::ensureDirectoryExists($basePath);
|
||||
$filesystem = new Filesystem;
|
||||
|
||||
if (! $dryRun) {
|
||||
File::ensureDirectoryExists($basePath);
|
||||
}
|
||||
|
||||
foreach ($selected as $index => $font) {
|
||||
if (! is_array($font) || ! isset($font['family'])) {
|
||||
@@ -73,11 +105,14 @@ class SyncGoogleFonts extends Command
|
||||
$family = (string) $font['family'];
|
||||
$slug = Str::slug($family);
|
||||
$familyDir = $basePath.DIRECTORY_SEPARATOR.$slug;
|
||||
File::ensureDirectoryExists($familyDir);
|
||||
if (! $dryRun) {
|
||||
File::ensureDirectoryExists($familyDir);
|
||||
}
|
||||
|
||||
$variantMap = $this->buildVariantMap($font, $weights, $includeItalic);
|
||||
if (! count($variantMap)) {
|
||||
$this->warn("Skipping {$family} (no matching variants)");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -89,7 +124,11 @@ class SyncGoogleFonts extends Command
|
||||
$filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension);
|
||||
$targetPath = $familyDir.DIRECTORY_SEPARATOR.$filename;
|
||||
|
||||
if (! $force && $filesystem->exists($targetPath)) {
|
||||
$alreadyExists = $filesystem->exists($targetPath);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf('◦ DRY RUN: %s %s would %s (%s)', $family, $variantKey, $alreadyExists && ! $force ? 'reuse existing file' : 'download', $targetPath));
|
||||
} elseif (! $force && $alreadyExists) {
|
||||
$this->line("✔ {$family} {$variantKey} already exists");
|
||||
} else {
|
||||
$this->line("↓ Downloading {$family} {$variantKey}");
|
||||
@@ -97,6 +136,7 @@ class SyncGoogleFonts extends Command
|
||||
|
||||
if (! $fileResponse->ok()) {
|
||||
$this->warn(" Skipped {$family} {$variantKey} (download failed)");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -124,6 +164,12 @@ class SyncGoogleFonts extends Command
|
||||
];
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info(sprintf('Dry run complete: %d font families would be synced to %s', count($manifestFonts), $basePath));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->pruneStaleFamilies($basePath, $manifestFonts);
|
||||
$this->writeManifest($basePath, $manifestFonts);
|
||||
$this->writeCss($basePath, $manifestFonts);
|
||||
@@ -134,6 +180,54 @@ class SyncGoogleFonts extends Command
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function normalizeFamilyOption(?string $family): ?string
|
||||
{
|
||||
$family = trim((string) $family);
|
||||
|
||||
return $family !== '' ? $family : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function prepareCategories(?string $categories): array
|
||||
{
|
||||
$parts = array_filter(array_map('trim', explode(',', (string) $categories)));
|
||||
|
||||
return array_values(array_unique(array_map(static function ($category) {
|
||||
$normalized = Str::of($category)->lower()->replace(' ', '-')->toString();
|
||||
|
||||
return (string) $normalized;
|
||||
}, $parts)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $items
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function filterFonts(array $items, ?string $family, array $categories): array
|
||||
{
|
||||
$filtered = collect($items)
|
||||
->filter(fn ($font) => is_array($font) && isset($font['family']))
|
||||
->filter(function ($font) use ($categories) {
|
||||
if (! count($categories)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$category = strtolower((string) ($font['category'] ?? ''));
|
||||
|
||||
return in_array($category, $categories, true);
|
||||
});
|
||||
|
||||
if ($family) {
|
||||
$filtered = $filtered->filter(function ($font) use ($family) {
|
||||
return strcasecmp((string) $font['family'], $family) === 0;
|
||||
});
|
||||
}
|
||||
|
||||
return $filtered->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
@@ -147,7 +241,7 @@ class SyncGoogleFonts extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $font
|
||||
* @param array<string, mixed> $font
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildVariantMap(array $font, array $weights, bool $includeItalic): array
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class EmotionController extends Controller
|
||||
{
|
||||
@@ -21,8 +22,15 @@ class EmotionController extends Controller
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
$query = Emotion::query()
|
||||
->whereNull('tenant_id')
|
||||
->orWhere('tenant_id', $tenantId)
|
||||
->when(true, function ($builder) use ($tenantId) {
|
||||
// Prefer tenant-specific and global emotions if the column exists
|
||||
if (Schema::hasColumn('emotions', 'tenant_id')) {
|
||||
$builder->where(function ($inner) use ($tenantId) {
|
||||
$inner->whereNull('tenant_id')
|
||||
->orWhere('tenant_id', $tenantId);
|
||||
});
|
||||
}
|
||||
})
|
||||
->with('eventTypes');
|
||||
|
||||
if ($request->boolean('only_tenant')) {
|
||||
@@ -35,7 +43,18 @@ class EmotionController extends Controller
|
||||
|
||||
$query->orderByRaw('tenant_id is null desc')->orderBy('sort_order')->orderBy('id');
|
||||
|
||||
$emotions = $query->paginate($request->integer('per_page', 25));
|
||||
$emotions = $query->paginate($request->integer('per_page', 50));
|
||||
|
||||
if ($emotions->isEmpty() && ! $request->boolean('only_tenant')) {
|
||||
// Fallback: return any emotions regardless of tenant to avoid empty selectors
|
||||
$fallback = Emotion::query()
|
||||
->with('eventTypes')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->paginate($request->integer('per_page', 50));
|
||||
|
||||
return EmotionResource::collection($fallback);
|
||||
}
|
||||
|
||||
return EmotionResource::collection($emotions);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class TaskCollectionController extends Controller
|
||||
$query = TaskCollection::query()
|
||||
->forTenant($tenantId)
|
||||
->with('eventType')
|
||||
->withCount('tasks')
|
||||
->withCount(['tasks', 'events'])
|
||||
->orderBy('position')
|
||||
->orderBy('id');
|
||||
|
||||
@@ -46,6 +46,27 @@ class TaskCollectionController extends Controller
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
if ($request->boolean('top_picks')) {
|
||||
if ($eventTypeSlug = $request->query('event_type')) {
|
||||
$query->where(function ($inner) use ($eventTypeSlug) {
|
||||
$inner->whereNull('event_type_id')
|
||||
->orWhereHas('eventType', fn ($q) => $q->where('slug', $eventTypeSlug));
|
||||
});
|
||||
}
|
||||
|
||||
$query->whereHas('tasks')
|
||||
->orderByDesc('events_count')
|
||||
->orderByDesc('updated_at')
|
||||
->orderBy('position')
|
||||
->orderBy('id');
|
||||
|
||||
$limit = $request->integer('limit', 3);
|
||||
|
||||
return TaskCollectionResource::collection(
|
||||
$query->limit($limit)->get()
|
||||
);
|
||||
}
|
||||
|
||||
$perPage = $request->integer('per_page', 15);
|
||||
|
||||
return TaskCollectionResource::collection(
|
||||
@@ -57,7 +78,8 @@ class TaskCollectionController extends Controller
|
||||
{
|
||||
$this->authorizeAccess($request, $collection);
|
||||
|
||||
$collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')]);
|
||||
$collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')])
|
||||
->loadCount(['tasks', 'events']);
|
||||
|
||||
return response()->json(new TaskCollectionResource($collection));
|
||||
}
|
||||
@@ -81,7 +103,11 @@ class TaskCollectionController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Task-Collection erfolgreich importiert.'),
|
||||
'collection' => new TaskCollectionResource($result['collection']->load('eventType')->loadCount('tasks')),
|
||||
'collection' => new TaskCollectionResource(
|
||||
$result['collection']
|
||||
->load('eventType')
|
||||
->loadCount(['tasks', 'events'])
|
||||
),
|
||||
'created_task_ids' => $result['created_task_ids'],
|
||||
'attached_task_ids' => $result['attached_task_ids'],
|
||||
]);
|
||||
|
||||
@@ -31,7 +31,7 @@ class TaskController extends Controller
|
||||
$inner->whereNull('tenant_id')
|
||||
->orWhere('tenant_id', $tenantId);
|
||||
})
|
||||
->with(['taskCollection', 'assignedEvents', 'eventType'])
|
||||
->with(['taskCollection', 'assignedEvents', 'eventType', 'emotion'])
|
||||
->orderByRaw('tenant_id is null desc')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('created_at', 'desc');
|
||||
@@ -80,7 +80,7 @@ class TaskController extends Controller
|
||||
|
||||
$task = Task::create($payload);
|
||||
|
||||
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
|
||||
$task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Task erfolgreich erstellt.',
|
||||
@@ -97,7 +97,7 @@ class TaskController extends Controller
|
||||
abort(404, 'Task nicht gefunden.');
|
||||
}
|
||||
|
||||
$task->load(['taskCollection', 'assignedEvents', 'eventType']);
|
||||
$task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']);
|
||||
|
||||
return response()->json(new TaskResource($task));
|
||||
}
|
||||
@@ -156,7 +156,7 @@ class TaskController extends Controller
|
||||
{
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($task->tenant_id !== $tenantId || $event->tenant_id !== $tenantId) {
|
||||
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
@@ -193,7 +193,9 @@ class TaskController extends Controller
|
||||
}
|
||||
|
||||
$tasks = Task::whereIn('id', $taskIds)
|
||||
->where('tenant_id', $tenantId)
|
||||
->where(function ($query) use ($tenantId) {
|
||||
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
|
||||
})
|
||||
->get();
|
||||
|
||||
$attached = 0;
|
||||
@@ -219,13 +221,63 @@ class TaskController extends Controller
|
||||
}
|
||||
|
||||
$tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id))
|
||||
->with(['taskCollection', 'eventType'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['taskCollection', 'eventType', 'emotion'])
|
||||
->orderBy('tasks.id')
|
||||
->paginate($request->get('per_page', 15));
|
||||
|
||||
return TaskResource::collection($tasks);
|
||||
}
|
||||
|
||||
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$taskIds = $request->input('task_ids', []);
|
||||
|
||||
if (empty($taskIds)) {
|
||||
return ApiError::response(
|
||||
'task_ids_missing',
|
||||
'Keine Aufgaben angegeben',
|
||||
'Bitte wähle mindestens eine Aufgabe aus.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$detached = $event->tasks()->whereIn('tasks.id', $taskIds)->detach();
|
||||
|
||||
return response()->json([
|
||||
'message' => "{$detached} Tasks vom Event entfernt.",
|
||||
]);
|
||||
}
|
||||
|
||||
public function reorderForEvent(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$taskIds = $request->input('task_ids', []);
|
||||
|
||||
if (empty($taskIds) || ! is_array($taskIds)) {
|
||||
return ApiError::response(
|
||||
'task_ids_missing',
|
||||
'Keine Aufgaben angegeben',
|
||||
'Bitte wähle mindestens eine Aufgabe aus.',
|
||||
Response::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Reihenfolge gespeichert.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks from a specific collection.
|
||||
*/
|
||||
|
||||
@@ -42,6 +42,8 @@ class EventStoreRequest extends FormRequest
|
||||
'features' => ['nullable', 'array'],
|
||||
'features.*' => ['string'],
|
||||
'settings' => ['nullable', 'array'],
|
||||
'settings.branding' => ['nullable', 'array'],
|
||||
'settings.branding.*' => ['nullable'],
|
||||
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class EventResource extends JsonResource
|
||||
'is_active' => (bool) ($this->is_active ?? false),
|
||||
'features' => $settings['features'] ?? [],
|
||||
'engagement_mode' => $settings['engagement_mode'] ?? 'tasks',
|
||||
'branding' => $settings['branding'] ?? null,
|
||||
'settings' => $settings,
|
||||
'event_type_id' => $this->event_type_id,
|
||||
'event_type' => $this->whenLoaded('eventType', function () {
|
||||
|
||||
@@ -32,7 +32,10 @@ class TaskCollectionResource extends JsonResource
|
||||
];
|
||||
}),
|
||||
'tasks_count' => $this->whenCounted('tasks'),
|
||||
'events_count' => $this->whenCounted('events'),
|
||||
'imports_count' => $this->whenCounted('events'),
|
||||
'is_default' => (bool) ($this->is_default ?? false),
|
||||
'is_mine' => $this->tenant_id !== null,
|
||||
'position' => $this->position,
|
||||
'source_collection_id' => $this->source_collection_id,
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
|
||||
@@ -41,9 +41,21 @@ class TaskResource extends JsonResource
|
||||
'eventType',
|
||||
fn () => new EventTypeResource($this->eventType)
|
||||
),
|
||||
'emotion_id' => $this->emotion_id,
|
||||
'emotion' => $this->whenLoaded(
|
||||
'emotion',
|
||||
fn () => [
|
||||
'id' => $this->emotion->id,
|
||||
'name' => $this->translatedText($this->normalizeTranslations($this->emotion->name), ''),
|
||||
'name_translations' => $this->emotion->name,
|
||||
'icon' => $this->emotion->icon,
|
||||
'color' => $this->emotion->color,
|
||||
]
|
||||
),
|
||||
'collection_id' => $this->collection_id,
|
||||
'source_task_id' => $this->source_task_id,
|
||||
'source_collection_id' => $this->source_collection_id,
|
||||
'sort_order' => $this->pivot?->sort_order,
|
||||
'assigned_events_count' => $assignedEventsCount,
|
||||
'assigned_events' => $this->whenLoaded(
|
||||
'assignedEvents',
|
||||
@@ -86,7 +98,7 @@ class TaskResource extends JsonResource
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $translations
|
||||
* @param array<string, string> $translations
|
||||
*/
|
||||
protected function translatedText(array $translations, string $fallback): string
|
||||
{
|
||||
@@ -109,4 +121,3 @@ class TaskResource extends JsonResource
|
||||
return $first !== false ? $first : $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ class Event extends Model
|
||||
public function tasks(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id')
|
||||
->withPivot(['sort_order'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ class Task extends Model
|
||||
public function assignedEvents(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id')
|
||||
->withPivot(['sort_order'])
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Support;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class WatermarkConfigResolver
|
||||
{
|
||||
@@ -18,7 +17,12 @@ class WatermarkConfigResolver
|
||||
$package = $event->eventPackages->first()?->package;
|
||||
}
|
||||
|
||||
return $package?->branding_allowed === true;
|
||||
// If no package is attached, default to allowing branding to avoid silently stripping event/tenant branding.
|
||||
if (! $package) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $package->branding_allowed !== false;
|
||||
}
|
||||
|
||||
public static function determinePolicy(Event $event): string
|
||||
|
||||
Reference in New Issue
Block a user