From 6c6d683b9a55827cd1cf3096ae4703404e8448f4 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 29 May 2026 15:57:33 +0000 Subject: [PATCH] Display CMS Optimierungen 29-05-2026 - Mediathek: Video-Vorschaubilder statt Icons (FFmpeg-Thumbnails + Backfill-Command), Kategorie "Sonstiges" - B2in Media-Picker zeigt alle Medientypen, Typ wird automatisch erkannt; Thumbnail-Preview vor allen Medien-URL-Feldern - B2in Marke/Footer: Footer ein/aus, Logo+Claim frei positionierbar (Ecken) mit Constraints, separate Anzeige-Schalter - Angebote-Modul dynamisch: kein Slide-Typ mehr, einheitliches Detail-Layout mit ein-/ausblendbaren Bloecken, Logo/Brand pro Slide, Streichpreis-Option - Player: leere Module stoppen Endlosschleife, dynamische Layout-Anpassung bei verstecktem Footer/Header - Fix: Script-Ladereihenfolge (Livewire vor Flux), entfernte stale public/flux/flux.js, Modal-Crash beim Aktualisieren behoben Co-authored-by: Cursor --- .env.example | 6 + .../Commands/GenerateVideoThumbnails.php | 59 + .../Api/DisplayPreviewController.php | 4 +- .../Api/ModulePreviewController.php | 11 +- app/Livewire/Admin/Cms/DisplayList.php | 41 + app/Livewire/Admin/Cms/DisplayMediaPicker.php | 3 +- .../Admin/Cms/DisplayVersionEditor.php | 194 +- app/Livewire/Admin/Cms/DisplayVersionList.php | 15 +- app/Livewire/Cabinet/QuickStatus.php | 11 +- app/Models/Display.php | 16 + app/Models/DisplayMedia.php | 8 + app/Services/DisplayMediaService.php | 77 +- app/Services/DisplayPlaylistConfigBuilder.php | 53 +- app/Support/DisplayModuleSettings.php | 11 +- database/seeders/DatabaseSeeder.php | 2 + database/seeders/DisplayVersionSeeder.php | 16 +- database/seeders/TestDisplaySeeder.php | 31 + dev/displays-11-05-2026/01-status.md | 67 +- phpunit.xml | 1 + public/_cabinet/display/index.html | 303 +- public/flux/flux.js | 13171 ---------------- .../components/layouts/app/header.blade.php | 1 + .../components/layouts/app/sidebar.blade.php | 4 +- .../components/layouts/auth/simple.blade.php | 12 - .../components/layouts/auth/split.blade.php | 1 + .../views/components/media-thumb.blade.php | 27 + .../livewire/admin/cms/display-list.blade.php | 19 +- .../admin/cms/display-media-library.blade.php | 58 +- .../admin/cms/display-media-picker.blade.php | 26 +- .../cms/display-version-editor.blade.php | 244 +- .../admin/cms/display-version-list.blade.php | 9 + .../partials/version-editor-offers.blade.php | 43 +- .../version-editor-settings.blade.php | 81 +- .../partials/version-editor-video.blade.php | 73 +- .../livewire/admin/hubs/manage.blade.php | 686 +- tests/Feature/DisplayListTest.php | 57 +- tests/Feature/DisplayMediaTest.php | 175 + tests/Feature/DisplayVersionApiTest.php | 97 +- tests/Feature/DisplayVersionTest.php | 389 +- tests/Feature/LayoutScriptOrderTest.php | 41 + tests/Feature/LiveSiteReviewTest.php | 10 +- tests/Feature/TestDisplaySeederTest.php | 19 + 42 files changed, 2267 insertions(+), 13905 deletions(-) create mode 100644 app/Console/Commands/GenerateVideoThumbnails.php create mode 100644 database/seeders/TestDisplaySeeder.php delete mode 100644 public/flux/flux.js create mode 100644 resources/views/components/media-thumb.blade.php create mode 100644 tests/Feature/LayoutScriptOrderTest.php create mode 100644 tests/Feature/TestDisplaySeederTest.php diff --git a/.env.example b/.env.example index ed229cd..4ffd7b0 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" +# Display-Player URL (Basis-URL der Player-Seite ohne ?id=). +# Live: https://cabinet.b2in.eu/display +# Lokal: https://portal.b2in.test/_cabinet/display (Player-Seite + API laufen auf der Portal-Domain) +# Ohne Wert greift der Produktions-Fallback aus config/display.php. +DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display + # Cookie Consent & Google Analytics (acme/cookie-consent) # GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX diff --git a/app/Console/Commands/GenerateVideoThumbnails.php b/app/Console/Commands/GenerateVideoThumbnails.php new file mode 100644 index 0000000..e325da0 --- /dev/null +++ b/app/Console/Commands/GenerateVideoThumbnails.php @@ -0,0 +1,59 @@ +where('type', 'video') + ->where('source_type', 'upload'); + + if (! $this->option('force')) { + $query->whereNull('thumbnail_path'); + } + + $videos = $query->get(); + + if ($videos->isEmpty()) { + $this->info('Keine Videos zum Verarbeiten gefunden.'); + + return self::SUCCESS; + } + + $this->info(sprintf('%d Video(s) werden verarbeitet...', $videos->count())); + + $generated = 0; + $failed = 0; + + foreach ($videos as $video) { + $thumbnailPath = $service->generateVideoThumbnail($video); + + if ($thumbnailPath !== null) { + $video->update(['thumbnail_path' => $thumbnailPath]); + $this->line(" {$video->getDisplayName()}"); + $generated++; + } else { + $this->warn(" ✗ {$video->getDisplayName()} (Poster konnte nicht erzeugt werden)"); + $failed++; + } + } + + $this->newLine(); + $this->info(sprintf('Fertig: %d erzeugt, %d fehlgeschlagen.', $generated, $failed)); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/DisplayPreviewController.php b/app/Http/Controllers/Api/DisplayPreviewController.php index 05cf830..a907c8d 100644 --- a/app/Http/Controllers/Api/DisplayPreviewController.php +++ b/app/Http/Controllers/Api/DisplayPreviewController.php @@ -16,7 +16,9 @@ class DisplayPreviewController extends Controller ->where('preview_token', $token) ->firstOrFail(); - return response()->file(public_path('_cabinet/display/index.html')); + return response()->file(public_path('_cabinet/display/index.html'), [ + 'Cache-Control' => 'no-cache, must-revalidate', + ]); } public function config(string $token, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse diff --git a/app/Http/Controllers/Api/ModulePreviewController.php b/app/Http/Controllers/Api/ModulePreviewController.php index 560184a..bec62e4 100644 --- a/app/Http/Controllers/Api/ModulePreviewController.php +++ b/app/Http/Controllers/Api/ModulePreviewController.php @@ -13,14 +13,21 @@ class ModulePreviewController extends Controller { public function show(DisplayVersion $module): BinaryFileResponse { - return response()->file(public_path('_cabinet/display/index.html')); + return $this->playerResponse(); } public function showItem(DisplayVersion $module, DisplayVersionItem $item): BinaryFileResponse { abort_unless($item->display_version_id === $module->id, 404); - return response()->file(public_path('_cabinet/display/index.html')); + return $this->playerResponse(); + } + + private function playerResponse(): BinaryFileResponse + { + return response()->file(public_path('_cabinet/display/index.html'), [ + 'Cache-Control' => 'no-cache, must-revalidate', + ]); } public function config(DisplayVersion $module, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse diff --git a/app/Livewire/Admin/Cms/DisplayList.php b/app/Livewire/Admin/Cms/DisplayList.php index 1a32b5c..698e540 100644 --- a/app/Livewire/Admin/Cms/DisplayList.php +++ b/app/Livewire/Admin/Cms/DisplayList.php @@ -30,6 +30,9 @@ class DisplayList extends Component public $addVersionSelect = null; + /** @var array */ + public $versionsToAdd = []; + public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED; public ?string $draftPreviewToken = null; @@ -75,6 +78,20 @@ class DisplayList extends Component $this->persistDraftPreviewIfNeeded(); } + public function addSelectedVersions(): void + { + foreach ($this->versionsToAdd as $versionId) { + $id = (int) $versionId; + + if ($id && ! in_array($id, $this->selectedVersionIds, true)) { + $this->selectedVersionIds[] = $id; + } + } + + $this->versionsToAdd = []; + $this->persistDraftPreviewIfNeeded(); + } + private function firstAvailableVersionId(): ?int { return DisplayVersion::active() @@ -175,10 +192,31 @@ class DisplayList extends Component } $display->draftPlaylist->delete(); + $display->clearPreviewToken(); session()->flash('success', 'Entwurf wurde verworfen.'); } + public function rotatePreviewToken(int $displayId): void + { + $display = Display::with('draftPlaylist')->findOrFail($displayId); + + if (! $display->draftPlaylist) { + session()->flash('success', 'Für dieses Display gibt es keinen Entwurf.'); + + return; + } + + $display->rotatePreviewToken(); + + if ($this->displayId === $display->id && $this->editingPlaylistStatus === DisplayPlaylist::STATUS_DRAFT) { + $this->draftPreviewToken = $display->preview_token; + $this->previewFrameRefreshCounter++; + } + + session()->flash('success', 'Vorschau-Link wurde neu erzeugt. Der alte Link ist jetzt ungültig.'); + } + public function publishDraft(int $displayId): void { $display = Display::with(['draftPlaylist.modules'])->findOrFail($displayId); @@ -201,6 +239,8 @@ class DisplayList extends Component return $display->draftPlaylist->fresh('modules'); }); + $display->clearPreviewToken(); + session()->flash('success', 'Entwurf wurde veröffentlicht.'); } @@ -314,6 +354,7 @@ class DisplayList extends Component $this->displayIsActive = true; $this->displayIsTest = false; $this->addVersionSelect = null; + $this->versionsToAdd = []; $this->editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED; $this->draftPreviewToken = null; $this->previewFrameRefreshCounter = 0; diff --git a/app/Livewire/Admin/Cms/DisplayMediaPicker.php b/app/Livewire/Admin/Cms/DisplayMediaPicker.php index 1c56870..27b2b90 100644 --- a/app/Livewire/Admin/Cms/DisplayMediaPicker.php +++ b/app/Livewire/Admin/Cms/DisplayMediaPicker.php @@ -117,8 +117,7 @@ class DisplayMediaPicker extends Component ->active() ->when($this->type === 'image', fn ($q) => $q->images()) ->when($this->type === 'video', fn ($q) => $q->videos()) - ->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%") - ->orWhere('title', 'like', "%{$this->search}%")) + ->when($this->search, fn ($q) => $q->search($this->search)) ->orderByDesc('created_at') ->paginate(18); } diff --git a/app/Livewire/Admin/Cms/DisplayVersionEditor.php b/app/Livewire/Admin/Cms/DisplayVersionEditor.php index 37d5561..ac5a318 100644 --- a/app/Livewire/Admin/Cms/DisplayVersionEditor.php +++ b/app/Livewire/Admin/Cms/DisplayVersionEditor.php @@ -3,10 +3,10 @@ namespace App\Livewire\Admin\Cms; use App\Enums\DisplayVersionType; +use App\Models\DisplayMedia; use App\Models\DisplayVersion; use App\Models\DisplayVersionItem; use App\Support\DisplayModuleSettings; -use Illuminate\Support\Facades\File; use Livewire\Attributes\On; use Livewire\Component; @@ -56,8 +56,12 @@ class DisplayVersionEditor extends Component public bool $mediaIsActive = true; - // Offers: Slide fields - public string $slideType = 'product-hero'; + // Offers: Slide fields (single dynamic detail layout) + public bool $slideShowLogo = true; + + public string $slideLogoUrl = ''; + + public string $slideBrandText = ''; public int $slideDuration = 8000; @@ -65,30 +69,46 @@ class DisplayVersionEditor extends Component public string $slideBadge = ''; + public bool $slideShowBadge = true; + public string $slideEyebrow = ''; + public bool $slideShowEyebrow = true; + public string $slideTitle = ''; public string $slideSubline = ''; + public bool $slideShowSubline = false; + public string $slidePrice = ''; public string $slideOriginalPrice = ''; + public bool $slideStrikeOriginalPrice = false; + public string $slideTagText = ''; + public bool $slideShowPrice = false; + /** @var array */ public array $slideBullets = []; + public bool $slideShowBullets = true; + public string $slideDisclaimer = ''; + public bool $slideShowDisclaimer = false; + public string $slideQrUrl = ''; public string $slideQrTitle = ''; + public bool $slideShowQr = true; + public string $slideContact = ''; - public bool $slideShowBrandText = false; + public bool $slideShowContact = true; public string $slideBrandTagline = ''; @@ -99,32 +119,25 @@ class DisplayVersionEditor extends Component public array $settings = []; - /** @var array */ - public array $availableVideos = []; - public int $previewFrameRefreshCounter = 0; + /** @var array */ + public const BRAND_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + public function mount(DisplayVersion $displayVersion): void { $this->version = $displayVersion; $this->versionName = $displayVersion->name; $this->settings = $this->settingsWithDefaults(); - - if ($this->version->type === DisplayVersionType::VideoDisplay) { - $this->loadAvailableVideos(); - } + $this->normalizeBrandPositions(); } - public function loadAvailableVideos(): void + public function updated(string $name): void { - $assetsPath = public_path('_cabinet/assets'); - - if (File::exists($assetsPath)) { - $this->availableVideos = collect(File::files($assetsPath)) - ->map(fn ($file) => $file->getFilename()) - ->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov'])) - ->values() - ->toArray(); + if (str_starts_with($name, 'settings.show_footer') + || str_starts_with($name, 'settings.logo_position') + || str_starts_with($name, 'settings.claim_position')) { + $this->normalizeBrandPositions(); } } @@ -160,12 +173,56 @@ class DisplayVersionEditor extends Component public function saveSettings(): void { + $this->normalizeBrandPositions(); $this->version->update(['settings' => $this->settings]); $this->showSettingsModal = false; $this->refreshModulePreview(); session()->flash('success', 'Einstellungen gespeichert!'); } + /** + * Keep the B2in logo/claim corners consistent: + * - bottom corners are only valid while the footer is hidden, + * - the claim can never sit in the same corner as the logo. + */ + private function normalizeBrandPositions(): void + { + if ($this->version->type !== DisplayVersionType::B2in) { + return; + } + + $footerShown = ($this->settings['show_footer'] ?? true) !== false; + $allowed = $footerShown ? ['top-left', 'top-right'] : self::BRAND_POSITIONS; + + $logo = $this->settings['logo_position'] ?? 'top-left'; + $claim = $this->settings['claim_position'] ?? 'top-right'; + + $logo = $this->moveIntoAllowed($logo, $allowed); + $claim = $this->moveIntoAllowed($claim, $allowed); + + if ($claim === $logo) { + $claim = collect($allowed)->first(fn (string $position) => $position !== $logo) ?? $claim; + } + + $this->settings['logo_position'] = $logo; + $this->settings['claim_position'] = $claim; + } + + /** + * @param array $allowed + */ + private function moveIntoAllowed(string $position, array $allowed): string + { + if (in_array($position, $allowed, true)) { + return $position; + } + + // Pull bottom corners up to the matching top corner when forbidden. + $fallback = str_replace('bottom-', 'top-', $position); + + return in_array($fallback, $allowed, true) ? $fallback : ($allowed[0] ?? 'top-left'); + } + // ======================================== // ITEM CRUD // ======================================== @@ -276,10 +333,20 @@ class DisplayVersionEditor extends Component 'videoFilename' => $this->videoFilename = $url, 'mediaUrl' => $this->mediaUrl = $url, 'slideImageUrl' => $this->slideImageUrl = $url, + 'slideLogoUrl' => $this->slideLogoUrl = $url, 'settings.header_logo_url' => $this->settings['header_logo_url'] = $url, - 'settings.logo_url' => $this->settings['logo_url'] = $url, default => null, }; + + // The media type for a B2in playlist item is derived from the chosen + // media – the type selector in the form is only an informational hint. + if ($field === 'mediaUrl' && $mediaId) { + $media = DisplayMedia::find($mediaId); + + if ($media) { + $this->mediaType = $media->isVideo() ? 'video' : 'image'; + } + } } public function addBullet(): void @@ -306,33 +373,43 @@ class DisplayVersionEditor extends Component private function loadItemContent(DisplayVersionItem $item): void { $content = $item->content; + $isActive = (bool) $item->is_active; match ($item->item_type) { - 'video' => $this->loadVideoContent($content), - 'footer' => $this->loadFooterContent($content), - 'media' => $this->loadMediaContent($content), - 'slide' => $this->loadSlideContent($content), + 'video' => $this->loadVideoContent($content, $isActive), + 'footer' => $this->loadFooterContent($content, $isActive), + 'media' => $this->loadMediaContent($content, $isActive), + 'slide' => $this->loadSlideContent($content, $isActive), default => null, }; } - private function loadVideoContent(array $content): void + /** + * @param array $content + */ + private function loadVideoContent(array $content, bool $isActive): void { $this->videoFilename = $content['filename'] ?? ''; $this->videoTitle = $content['title'] ?? ''; $this->videoPosition = $content['position'] ?? 25; - $this->videoIsActive = true; + $this->videoIsActive = $isActive; } - private function loadFooterContent(array $content): void + /** + * @param array $content + */ + private function loadFooterContent(array $content, bool $isActive): void { $this->footerHeadline = $content['headline'] ?? ''; $this->footerSubline = $content['subline'] ?? ''; $this->footerUrl = $content['url'] ?? ''; - $this->footerIsActive = true; + $this->footerIsActive = $isActive; } - private function loadMediaContent(array $content): void + /** + * @param array $content + */ + private function loadMediaContent(array $content, bool $isActive): void { $this->mediaType = $content['media_type'] ?? 'image'; $this->mediaCategory = $content['category'] ?? 'immobilien'; @@ -340,12 +417,17 @@ class DisplayVersionEditor extends Component $this->mediaHeadline = $content['headline'] ?? ''; $this->mediaSubline = $content['subline'] ?? ''; $this->mediaDuration = $content['duration_seconds'] ?? 10; - $this->mediaIsActive = true; + $this->mediaIsActive = $isActive; } - private function loadSlideContent(array $content): void + /** + * @param array $content + */ + private function loadSlideContent(array $content, bool $isActive): void { - $this->slideType = $content['type'] ?? 'product-hero'; + $this->slideShowLogo = $content['show_logo'] ?? true; + $this->slideLogoUrl = $content['logo_url'] ?? ''; + $this->slideBrandText = $content['brand_text'] ?? ''; $this->slideDuration = $content['duration'] ?? 8000; $this->slideImageUrl = $content['image_url'] ?? ''; $this->slideBadge = $content['badge_text'] ?? ''; @@ -354,15 +436,26 @@ class DisplayVersionEditor extends Component $this->slideSubline = $content['subline'] ?? ''; $this->slidePrice = $content['price'] ?? ''; $this->slideOriginalPrice = $content['original_price'] ?? ''; + $this->slideStrikeOriginalPrice = $content['strike_original_price'] ?? false; $this->slideTagText = $content['tag_text'] ?? ''; $this->slideBullets = $content['bullets'] ?? []; $this->slideDisclaimer = $content['disclaimer'] ?? ''; $this->slideQrUrl = $content['qr_url'] ?? ''; $this->slideQrTitle = $content['qr_title'] ?? ''; $this->slideContact = $content['contact'] ?? ''; - $this->slideShowBrandText = $content['show_brand_text'] ?? false; $this->slideBrandTagline = $content['brand_tagline'] ?? ''; - $this->slideIsActive = true; + $this->slideIsActive = $isActive; + + // Show flags fall back to "is there content?" so slides created before + // the dynamic detail layout keep rendering exactly as they did. + $this->slideShowBadge = $content['show_badge'] ?? ($content['badge_text'] ?? '') !== ''; + $this->slideShowEyebrow = $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== ''; + $this->slideShowSubline = $content['show_subline'] ?? ($content['subline'] ?? '') !== ''; + $this->slideShowBullets = $content['show_bullets'] ?? ! empty($content['bullets']); + $this->slideShowPrice = $content['show_price'] ?? ($content['price'] ?? '') !== ''; + $this->slideShowDisclaimer = $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== ''; + $this->slideShowQr = $content['show_qr'] ?? ($content['qr_url'] ?? '') !== ''; + $this->slideShowContact = $content['show_contact'] ?? ($content['contact'] ?? '') !== ''; } /** @@ -390,22 +483,33 @@ class DisplayVersionEditor extends Component 'duration_seconds' => $this->mediaDuration, ], 'slide' => [ - 'type' => $this->slideType, + 'type' => 'detail', + 'show_logo' => $this->slideShowLogo, + 'logo_url' => $this->slideLogoUrl, + 'brand_text' => $this->slideBrandText, 'duration' => $this->slideDuration, 'image_url' => $this->slideImageUrl, 'badge_text' => $this->slideBadge, + 'show_badge' => $this->slideShowBadge, 'eyebrow' => $this->slideEyebrow, + 'show_eyebrow' => $this->slideShowEyebrow, 'title' => $this->slideTitle, 'subline' => $this->slideSubline, + 'show_subline' => $this->slideShowSubline, 'price' => $this->slidePrice, 'original_price' => $this->slideOriginalPrice, + 'strike_original_price' => $this->slideStrikeOriginalPrice, 'tag_text' => $this->slideTagText, - 'bullets' => $this->slideBullets, + 'show_price' => $this->slideShowPrice, + 'bullets' => array_values(array_filter($this->slideBullets, fn (string $bullet) => trim($bullet) !== '')), + 'show_bullets' => $this->slideShowBullets, 'disclaimer' => $this->slideDisclaimer, + 'show_disclaimer' => $this->slideShowDisclaimer, 'qr_url' => $this->slideQrUrl, 'qr_title' => $this->slideQrTitle, + 'show_qr' => $this->slideShowQr, 'contact' => $this->slideContact, - 'show_brand_text' => $this->slideShowBrandText, + 'show_contact' => $this->slideShowContact, 'brand_tagline' => $this->slideBrandTagline, ], default => [], @@ -451,22 +555,32 @@ class DisplayVersionEditor extends Component $this->mediaSubline = ''; $this->mediaDuration = 10; $this->mediaIsActive = true; - $this->slideType = 'product-hero'; + $this->slideShowLogo = true; + $this->slideLogoUrl = ''; + $this->slideBrandText = ''; $this->slideDuration = 8000; $this->slideImageUrl = ''; $this->slideBadge = ''; + $this->slideShowBadge = true; $this->slideEyebrow = ''; + $this->slideShowEyebrow = true; $this->slideTitle = ''; $this->slideSubline = ''; + $this->slideShowSubline = false; $this->slidePrice = ''; $this->slideOriginalPrice = ''; + $this->slideStrikeOriginalPrice = false; $this->slideTagText = ''; + $this->slideShowPrice = false; $this->slideBullets = []; + $this->slideShowBullets = true; $this->slideDisclaimer = ''; + $this->slideShowDisclaimer = false; $this->slideQrUrl = ''; $this->slideQrTitle = ''; + $this->slideShowQr = true; $this->slideContact = ''; - $this->slideShowBrandText = false; + $this->slideShowContact = true; $this->slideBrandTagline = ''; $this->slideIsActive = true; } diff --git a/app/Livewire/Admin/Cms/DisplayVersionList.php b/app/Livewire/Admin/Cms/DisplayVersionList.php index dad96a1..80dd21e 100644 --- a/app/Livewire/Admin/Cms/DisplayVersionList.php +++ b/app/Livewire/Admin/Cms/DisplayVersionList.php @@ -54,7 +54,20 @@ class DisplayVersionList extends Component public function deleteVersion(int $id): void { - $version = DisplayVersion::findOrFail($id); + $version = DisplayVersion::query() + ->withCount([ + 'playlistItems as displays_count' => fn ($query) => $query + ->join('display_playlists', 'display_playlist_items.display_playlist_id', '=', 'display_playlists.id') + ->select(DB::raw('count(distinct display_playlists.display_id)')), + ]) + ->findOrFail($id); + + if ($version->displays_count > 0) { + session()->flash('error', 'Modul "'.$version->name.'" wird noch von '.$version->displays_count.' Display(s) verwendet und kann nicht gelöscht werden. Entfernen Sie es zuerst aus den betroffenen Bespielungen.'); + + return; + } + $name = $version->name; $version->delete(); diff --git a/app/Livewire/Cabinet/QuickStatus.php b/app/Livewire/Cabinet/QuickStatus.php index 47dcd6a..a97410d 100644 --- a/app/Livewire/Cabinet/QuickStatus.php +++ b/app/Livewire/Cabinet/QuickStatus.php @@ -45,10 +45,10 @@ class QuickStatus extends Component ], ]; - public function mount(): void + public function mount(?string $k = null): void { $validKey = config('domains.cabinet_status_key'); - $key = request()->get('key'); + $key = $k ?? request()->query('k'); if (! $validKey || $key !== $validKey) { $this->authorized = false; @@ -92,6 +92,13 @@ class QuickStatus extends Component 'noticeSubtext.max' => 'Subtext max. 80 Zeichen.', ]); + $showsNotice = in_array($this->storeStatus, ['notice', 'warning'], true); + + if (! $showsNotice) { + $this->noticeHeadline = ''; + $this->noticeSubtext = ''; + } + CabinetTabletSetting::current()->update([ 'store_status' => $this->storeStatus, 'notice_headline' => $this->noticeHeadline ?: null, diff --git a/app/Models/Display.php b/app/Models/Display.php index f73082a..73d705d 100644 --- a/app/Models/Display.php +++ b/app/Models/Display.php @@ -64,4 +64,20 @@ class Display extends Model return $this->preview_token; } + + public function rotatePreviewToken(): string + { + $this->preview_token = Str::random(40); + $this->save(); + + return $this->preview_token; + } + + public function clearPreviewToken(): void + { + if ($this->preview_token !== null) { + $this->preview_token = null; + $this->save(); + } + } } diff --git a/app/Models/DisplayMedia.php b/app/Models/DisplayMedia.php index 3cf2397..eb91036 100644 --- a/app/Models/DisplayMedia.php +++ b/app/Models/DisplayMedia.php @@ -150,4 +150,12 @@ class DisplayMedia extends Model { return $query->where('collection', $collection); } + + public function scopeSearch(Builder $query, string $term): Builder + { + return $query->where(function (Builder $query) use ($term): void { + $query->where('filename', 'like', "%{$term}%") + ->orWhere('title', 'like', "%{$term}%"); + }); + } } diff --git a/app/Services/DisplayMediaService.php b/app/Services/DisplayMediaService.php index bc08115..f62c459 100644 --- a/app/Services/DisplayMediaService.php +++ b/app/Services/DisplayMediaService.php @@ -7,6 +7,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Symfony\Component\Process\Process; class DisplayMediaService { @@ -34,7 +35,7 @@ class DisplayMediaService } } - return DisplayMedia::create([ + $media = DisplayMedia::create([ 'filename' => $filename, 'disk' => 'public', 'path' => $relativePath, @@ -45,6 +46,80 @@ class DisplayMediaService 'collection' => $collection, 'metadata' => ! empty($metadata) ? $metadata : null, ]); + + if ($type === 'video') { + $thumbnailPath = $this->generateVideoThumbnail($media); + + if ($thumbnailPath !== null) { + $media->update(['thumbnail_path' => $thumbnailPath]); + } + } + + return $media; + } + + /** + * Generate a poster frame for an uploaded video using ffmpeg. + * + * Returns the relative thumbnail path on the media's disk, or null when + * generation is not possible (e.g. ffmpeg missing or an unreadable file). + */ + public function generateVideoThumbnail(DisplayMedia $media): ?string + { + if (! $media->isVideo() || ! $media->isUpload() || ! $media->path) { + return null; + } + + $disk = Storage::disk($media->disk); + $videoPath = $disk->path($media->path); + + if (! is_file($videoPath)) { + return null; + } + + $thumbnailRelativePath = preg_replace('/\.[^.\/]+$/', '', $media->path).'-poster.jpg'; + $thumbnailPath = $disk->path($thumbnailRelativePath); + + if (! is_dir(dirname($thumbnailPath))) { + @mkdir(dirname($thumbnailPath), 0755, true); + } + + // Try a frame ~1s in first (avoids black intro frames); fall back to + // the very first frame for clips shorter than one second. + foreach (['1', '0'] as $seekSeconds) { + if ($this->extractFrame($videoPath, $thumbnailPath, $seekSeconds) && filesize($thumbnailPath) > 0) { + return $thumbnailRelativePath; + } + } + + if (is_file($thumbnailPath)) { + @unlink($thumbnailPath); + } + + return null; + } + + private function extractFrame(string $videoPath, string $thumbnailPath, string $seekSeconds): bool + { + $process = new Process([ + 'ffmpeg', + '-y', + '-ss', $seekSeconds, + '-i', $videoPath, + '-frames:v', '1', + '-vf', 'scale=640:-2', + '-q:v', '3', + $thumbnailPath, + ]); + $process->setTimeout(60); + + try { + $process->run(); + } catch (\Throwable) { + return false; + } + + return $process->isSuccessful() && is_file($thumbnailPath); } /** diff --git a/app/Services/DisplayPlaylistConfigBuilder.php b/app/Services/DisplayPlaylistConfigBuilder.php index 2163ed8..46c7414 100644 --- a/app/Services/DisplayPlaylistConfigBuilder.php +++ b/app/Services/DisplayPlaylistConfigBuilder.php @@ -143,25 +143,40 @@ class DisplayPlaylistConfigBuilder */ private function offersData(DisplayVersion $module, Collection $items): array { - $slides = $items->where('item_type', 'slide')->values()->map(fn ($item) => [ - 'type' => $item->content['type'] ?? 'product-hero', - 'duration' => $item->content['duration'] ?? 8000, - 'image_url' => $item->content['image_url'] ?? '', - 'badge_text' => $item->content['badge_text'] ?? '', - 'eyebrow' => $item->content['eyebrow'] ?? '', - 'title' => $item->content['title'] ?? '', - 'subline' => $item->content['subline'] ?? '', - 'price' => $item->content['price'] ?? '', - 'original_price' => $item->content['original_price'] ?? '', - 'tag_text' => $item->content['tag_text'] ?? '', - 'bullets' => $item->content['bullets'] ?? [], - 'disclaimer' => $item->content['disclaimer'] ?? '', - 'qr_url' => $item->content['qr_url'] ?? '', - 'qr_title' => $item->content['qr_title'] ?? '', - 'contact' => $item->content['contact'] ?? '', - 'show_brand_text' => $item->content['show_brand_text'] ?? false, - 'brand_tagline' => $item->content['brand_tagline'] ?? '', - ]); + $slides = $items->where('item_type', 'slide')->values()->map(function ($item) { + $content = $item->content; + + return [ + 'type' => $content['type'] ?? 'detail', + 'show_logo' => $content['show_logo'] ?? true, + 'logo_url' => $content['logo_url'] ?? '', + 'brand_text' => $content['brand_text'] ?? '', + 'duration' => $content['duration'] ?? 8000, + 'image_url' => $content['image_url'] ?? '', + 'badge_text' => $content['badge_text'] ?? '', + 'show_badge' => $content['show_badge'] ?? ($content['badge_text'] ?? '') !== '', + 'eyebrow' => $content['eyebrow'] ?? '', + 'show_eyebrow' => $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== '', + 'title' => $content['title'] ?? '', + 'subline' => $content['subline'] ?? '', + 'show_subline' => $content['show_subline'] ?? ($content['subline'] ?? '') !== '', + 'price' => $content['price'] ?? '', + 'original_price' => $content['original_price'] ?? '', + 'strike_original_price' => $content['strike_original_price'] ?? false, + 'tag_text' => $content['tag_text'] ?? '', + 'show_price' => $content['show_price'] ?? ($content['price'] ?? '') !== '', + 'bullets' => $content['bullets'] ?? [], + 'show_bullets' => $content['show_bullets'] ?? ! empty($content['bullets']), + 'disclaimer' => $content['disclaimer'] ?? '', + 'show_disclaimer' => $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '', + 'qr_url' => $content['qr_url'] ?? '', + 'qr_title' => $content['qr_title'] ?? '', + 'show_qr' => $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '', + 'contact' => $content['contact'] ?? '', + 'show_contact' => $content['show_contact'] ?? ($content['contact'] ?? '') !== '', + 'brand_tagline' => $content['brand_tagline'] ?? '', + ]; + }); return [ 'type' => 'offers', diff --git a/app/Support/DisplayModuleSettings.php b/app/Support/DisplayModuleSettings.php index fc3bc1d..2fcad41 100644 --- a/app/Support/DisplayModuleSettings.php +++ b/app/Support/DisplayModuleSettings.php @@ -21,6 +21,11 @@ class DisplayModuleSettings 'theme' => 'dark', 'header_logo_url' => '../assets/b2in-logo-positive.svg', 'header_claim' => 'Connecting Design & Property', + 'logo_position' => 'top-left', + 'claim_position' => 'top-right', + 'show_logo' => true, + 'show_claim' => true, + 'show_footer' => true, 'footer_url' => 'B2in.eu', 'footer_name' => '', 'footer_prefix' => 'by', @@ -35,12 +40,6 @@ class DisplayModuleSettings ], DisplayVersionType::Offers->value => [ 'loop' => true, - 'logo_url' => '../logo-cabinet-300.png', - 'brand_text' => 'Bielefeld', - 'footer_claim' => '', - 'footer_url' => '', - 'qr_default_title' => 'Kontakt', - 'qr_subtitle' => 'QR scannen', 'transition' => [ 'type' => 'fade', 'duration' => 600, diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 0f83fd4..b40ae54 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,5 +21,7 @@ class DatabaseSeeder extends Seeder 'email' => 'kevin.adametz@me.com', 'password' => Hash::make('xunfew-0Jygjy-minnyt'), ]); + + $this->call(TestDisplaySeeder::class); } } diff --git a/database/seeders/DisplayVersionSeeder.php b/database/seeders/DisplayVersionSeeder.php index d6a3357..4d27410 100644 --- a/database/seeders/DisplayVersionSeeder.php +++ b/database/seeders/DisplayVersionSeeder.php @@ -169,7 +169,9 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Kontakt', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_brand_text' => true, + 'show_logo' => true, + 'logo_url' => '../logo-cabinet-300.png', + 'brand_text' => 'Bielefeld', 'brand_tagline' => "Planung • Beratung\nLieferung & Montage", ], [ @@ -188,7 +190,9 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Reservieren', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_brand_text' => false, + 'show_logo' => true, + 'logo_url' => '../logo-cabinet-300.png', + 'brand_text' => '', 'brand_tagline' => '', ], [ @@ -212,7 +216,9 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Reservieren', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_brand_text' => false, + 'show_logo' => true, + 'logo_url' => '../logo-cabinet-300.png', + 'brand_text' => '', 'brand_tagline' => '', ], [ @@ -231,7 +237,9 @@ class DisplayVersionSeeder extends Seeder 'qr_url' => 'https://cabinet-bielefeld.de', 'qr_title' => 'Sichern', 'contact' => "0521 98620100\nTel. oder WhatsApp", - 'show_brand_text' => false, + 'show_logo' => true, + 'logo_url' => '../logo-cabinet-300.png', + 'brand_text' => '', 'brand_tagline' => '', ], ]; diff --git a/database/seeders/TestDisplaySeeder.php b/database/seeders/TestDisplaySeeder.php new file mode 100644 index 0000000..8bc6729 --- /dev/null +++ b/database/seeders/TestDisplaySeeder.php @@ -0,0 +1,31 @@ +where('is_test', true)->exists()) { + $this->command?->info('Test-Display existiert bereits – übersprungen.'); + + return; + } + + Display::query()->create([ + 'name' => 'Test-Display', + 'location' => 'Vorschau / Test', + 'is_active' => true, + 'is_test' => true, + ]); + + $this->command?->info('Test-Display angelegt.'); + } +} diff --git a/dev/displays-11-05-2026/01-status.md b/dev/displays-11-05-2026/01-status.md index 8389a3d..16628d7 100644 --- a/dev/displays-11-05-2026/01-status.md +++ b/dev/displays-11-05-2026/01-status.md @@ -13,7 +13,8 @@ | **4** | Admin-UI: Entwurf-Editor (Iframe-Vorschau) | ✅ 12.05.2026 | | **5** | Modul-Editor: 3-stufige Vorschau | ✅ 12.05.2026 | | **6** | Umbenennung Versionen → Module + Onboarding | ✅ 12.05.2026 | -| **7** | Aufräumen + alte Pivot-Tabelle entfernen | ⏳ offen | +| **7** | Aufräumen + alte Pivot-Tabelle entfernen | ✅ 13.05.2026 | +| **8** | Review: Fehler / Optimierungen / Erweiterungen | 🟡 29.05.2026 (Befundaufnahme) | Legende: ✅ fertig · 🟡 in Arbeit · ⏳ offen · ⛔ blockiert @@ -308,3 +309,67 @@ Umsetzung: - Admin-Iframes laden per `loading="lazy"` verzögert, um die parallelen Player-Vorschauen leichter zu halten - Video-Display-Items zeigen im Editor sichtbar an, ob die Quelle aus der Mediathek oder aus einem Legacy-Dateinamen kommt +--- + +## Phase 8 – Review 29.05.2026 (Befundaufnahme) + +Geprüft wurden die drei Navigationspunkte: +- `admin/cms/display-media` → Volt `admin.cms.display-media-library` +- `admin/cms/display-modules` → `App\Livewire\Admin\Cms\DisplayVersionList` (+ Editor `DisplayVersionEditor`) +- `admin/cms/displays` → `App\Livewire\Admin\Cms\DisplayList` + +**Vorgehen:** Code-Review der Komponenten/Views/Services/Player + Laravel-Logs + DB-Stand. Eine Browser-Sichtprüfung war in dieser Session nicht möglich (Browser-MCP nicht verfügbar). **Alle Befunde (#1–#11) wurden inzwischen umgesetzt** (Details je Punkt unten). + +**Aktueller Datenstand (DB):** 5 Displays (davon **0** Test-Displays), 6 Module, 7 Playlists (5 Live + 2 Entwürfe), 13 Medien. + +**Tests:** `DisplayListTest`, `DisplayMediaTest`, `DisplayVersionTest`, `DisplayVersionApiTest`, `DisplayPlaylistMigrationTest` → **95 passed** (276 Assertions). Keine CMS-Fehler in `storage/logs` (vorhandene Log-Fehler betreffen unrelated `immobilien-azizi`-Routen). + +### 🔴 Fehler (sollten behoben werden) + +1. **Mediathek-Suche hebelt alle Filter aus.** ✅ **behoben 29.05.2026** + In `display-media-library.blade.php` (`$media`-Computed) und in `DisplayMediaPicker::resolveMediaItems()` wurde die Suche als `->where('filename','like',…)->orWhere('title','like',…)` ohne Gruppierung an die vorherigen `when()`-Filter gehängt. Durch SQL-Präzedenz (`AND` bindet stärker als `OR`) wurde die Query zu `… AND filename LIKE … OR title LIKE …`. Sobald gesucht wurde, wurden **Typ-, Quelle-, Sammlungs- und (beim Picker) der `active()`-Filter ignoriert**, sobald ein Treffer über `title` zustande kam. + *Behebung:* Neuer gekapselter Scope `DisplayMedia::scopeSearch()` (Closure um `filename`/`title`), den Library und Picker gemeinsam nutzen. Tests: `keeps preceding filters when combined with the search scope`, `search scope respects the active filter on the media picker` in `DisplayMediaTest.php`. + +2. **Inhalt-Bearbeiten reaktiviert deaktivierte Items.** ✅ **behoben 29.05.2026** + In `DisplayVersionEditor` übergab `loadItemContent()` nur das `content`-Array an `loadVideoContent()/loadFooterContent()/loadMediaContent()/loadSlideContent()`. Alle vier setzten `…IsActive = true` fest – das tatsächliche `is_active` des Items wurde nie geladen. Wer ein zuvor per Auge-Icon deaktiviertes Item öffnete und „Aktualisieren" klickte, hat es über `getActiveFlag()` **ungewollt wieder aktiviert**. + *Behebung:* `loadItemContent()` reicht `(bool) $item->is_active` an die Loader durch, die es in die jeweilige `…IsActive`-Property schreiben. Test: `editing an inactive item keeps it inactive` in `DisplayVersionTest.php`. + +### 🟡 Optimierungen / Aufräumen + +3. **Toter Code im Video-Editor.** ✅ **behoben 29.05.2026** `DisplayVersionEditor::loadAvailableVideos()`, Property `$availableVideos`, der `mount()`-Aufruf und der ungenutzte `Illuminate\Support\Facades\File`-Import wurden entfernt (im aktiven Editor-Blade ungenutzt; das einzige Blade mit `availableVideos` gehört zur Legacy-`CabinetDisplay`). Spart den Dateisystem-Scan von `public/_cabinet/assets` bei jedem Editor-Aufruf. + +4. **Modul-Löschen ohne Schutz.** ✅ **behoben 29.05.2026** `DisplayVersionList::deleteVersion()` ermittelt jetzt den `displays_count` (distinct Displays über Playlists) und **blockiert** das Löschen, solange das Modul in irgendeiner Bespielung (Live oder Entwurf) genutzt wird; es erscheint ein roter Flash-Hinweis mit Anzahl. Module ohne Nutzung lassen sich weiterhin löschen. Test: `cannot delete a display version that is used by a playlist` in `DisplayVersionTest.php`. + +5. **Doppelte Such-Logik** zwischen Library und Picker (siehe #1) → ein gemeinsamer Query-Scope `DisplayMedia::scopeSearch(string $term)` verhindert künftige Divergenz. ✅ **erledigt 29.05.2026** (im Zuge von #1). + +6. **Preview-Token bleibt nach Veröffentlichen bestehen.** ✅ **behoben 29.05.2026** + `DisplayList::publishDraft()`/`discardDraft()` ließen `displays.preview_token` gesetzt, obwohl `/preview/{token}` danach 404 lieferte (kein Draft mehr); ein Rotations-Button fehlte ganz. + *Behebung:* Neue Model-Methoden `Display::rotatePreviewToken()` und `Display::clearPreviewToken()`. `publishDraft()` und `discardDraft()` setzen den Token jetzt zurück (alter Link wird ungültig). Im Entwurf gibt es einen „Link erneuern"-Button (`rotatePreviewToken()` mit `wire:confirm`), der bei offenem Entwurf zugleich die Iframe-Vorschau aktualisiert. Tests: `publishing a draft clears the preview token`, `can discard a draft playlist` (erweitert) und `can rotate the preview token of a draft` in `DisplayListTest.php`. + +### 🟠 Konfiguration / Dev-Umgebung + +7. **Live-Vorschau zeigt im lokalen Dev auf die Produktion.** ✅ **behoben 29.05.2026** + `config/display.php` `player_url` defaultete auf `https://cabinet.b2in.eu/display`, und `DISPLAY_PLAYER_URL` war in `.env` nicht gesetzt. In `display-list.blade.php` nutzten „Vorschau", „Live-URL zum Kopieren" und „Display-Übersicht öffnen" diese Produktions-URL, während die Entwurfs-„Test-URL" über `url('/preview/…')` lokal lief. Lokal sah man Live also den Produktionsstand, Entwurf aber lokale Daten – inkonsistent und irreführend. + *Behebung:* Player-Seite (`public/_cabinet/display/`) **und** die Display-API liegen auf der Portal-Domain, daher ist der Player lokal unter `https://portal.b2in.test/_cabinet/display` erreichbar (der Player ermittelt `BASE_URL` aus `window.location.origin` und ruft die API auf derselben Domain auf). + - `.env`: `DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display` gesetzt (Config-Cache geleert). + - `.env.example`: dokumentiert (Live-Wert als Kommentar). + - `phpunit.xml`: `DISPLAY_PLAYER_URL=https://cabinet.b2in.eu/display` gepinnt, damit die bestehenden URL-Tests (`DisplayListTest`, `DisplayVersionApiTest`) deterministisch gegen den Produktionswert prüfen, unabhängig von der lokalen `.env`. + - Der Produktions-Fallback in `config/display.php` (und damit das Live-Verhalten) bleibt unverändert. + +### 🟢 Erweiterungen / Verbesserungen (UX) + +8. **Test-Display existiert nicht.** ✅ **behoben 29.05.2026** Neuer idempotenter `Database\Seeders\TestDisplaySeeder` legt genau **ein** Test-Display an (überspringt, falls schon vorhanden) und ist im `DatabaseSeeder` registriert. In der Dev-DB wurde das Test-Display angelegt. Tests: `seeds exactly one test display`, `is idempotent and does not create a second test display` in `TestDisplaySeederTest.php`. + +9. **Externe Bilder ohne Thumbnail.** ✅ **behoben 29.05.2026** Grid, Listenansicht und Detail-Sidebar (`display-media-library.blade.php`) rendern externe Bilder jetzt direkt via `` (gemeinsame `$thumbSrc`-Logik: Upload-Thumbnail → sonst `external_url`). Nicht-Bild-Externe behalten das Link-Icon. Test: `renders external image media with an inline thumbnail` in `DisplayMediaTest.php`. + +10. **Mediathek-Pagination ohne `resetPage`.** ✅ **behoben 29.05.2026** Die Volt-Komponente nutzt jetzt `WithPagination` und Update-Hooks (`updatedSearch/updatedFilterType/updatedFilterSource/updatedFilterCollection`), die bei jeder Filter-/Suchänderung `resetPage()` aufrufen. Test: `resets pagination to page one when a filter changes` in `DisplayMediaTest.php`. + +11. **Modul-Hinzufügen nur einzeln.** ✅ **behoben 29.05.2026** Der Bespielungs-Dialog (`display-list.blade.php`) nutzt jetzt einen durchsuchbaren Flux-Listbox-Multi-Select (`versionsToAdd`) plus „Hinzufügen"-Button (`addSelectedVersions()`), der alle ausgewählten Module auf einmal übernimmt (Duplikate werden übersprungen). Die programmatische `addVersion()`-Methode bleibt für Einzel-Adds/Tests erhalten. Test: `can add multiple modules to a playlist at once` in `DisplayListTest.php`. + +### Empfohlene Reihenfolge + +1. #1 + #2 (echte Fehler, klein, mit Tests) → 2. #7 (Dev-Konfiguration, blockiert lokales Testen) → 3. #3/#4/#5/#6 (Aufräumen/Robustheit) → 4. #8–#11 (UX-Erweiterungen). Jeder Punkt wird gemäß Projekt-Regeln mit Test abgesichert und mit Pint formatiert. + +**Stand 29.05.2026:** Alle Befunde (#1–#11) sind umgesetzt, getestet und mit Pint formatiert. + + diff --git a/phpunit.xml b/phpunit.xml index 5cf230b..4dbc029 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -20,6 +20,7 @@ + diff --git a/public/_cabinet/display/index.html b/public/_cabinet/display/index.html index 96e9eff..c166923 100644 --- a/public/_cabinet/display/index.html +++ b/public/_cabinet/display/index.html @@ -143,6 +143,24 @@ text-transform: uppercase; color: rgba(255,255,255,0.7); } + /* B2in Brand (positionable logo + claim) */ + .b2in-brand { position: absolute; z-index: 12; display: flex; align-items: center; max-width: 60%; } + .b2in-brand-logo img { height: 3.5vh; display: block; filter: drop-shadow(0 1px 4px rgba(0,0,0,0.45)); } + .b2in-brand-claim { + font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em; + text-transform: uppercase; color: rgba(255,255,255,0.85); + text-shadow: 0 1px 4px rgba(0,0,0,0.55); + } + .b2in-brand.pos-top-left { top: 2.5vh; left: 3vh; } + .b2in-brand.pos-top-right { top: 2.5vh; right: 3vh; } + .b2in-brand.pos-bottom-left { bottom: 2.5vh; left: 3vh; } + .b2in-brand.pos-bottom-right { bottom: 2.5vh; right: 3vh; } + + /* Legibility scrims behind positioned brand elements */ + .b2in-scrim { position: absolute; left: 0; right: 0; height: 12vh; z-index: 11; pointer-events: none; } + .b2in-scrim-top { top: 0; background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); } + .b2in-scrim-bottom { bottom: 0; background: linear-gradient(to top, rgba(0,0,0,0.6), transparent); } + /* B2in Media */ .b2in-media { flex: 1; position: relative; overflow: hidden; @@ -171,6 +189,8 @@ font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7); line-height: 1.4; } + /* Without footer the text reclaims the footer's space at the bottom */ + .b2in-layer.no-footer .b2in-text { padding-bottom: 4vh; } /* B2in Footer */ .b2in-footer { @@ -204,6 +224,9 @@ background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent); } .b2in-layer[data-theme="light"] .b2in-claim { color: rgba(43,63,81,0.6); } + .b2in-layer[data-theme="light"] .b2in-brand-claim { color: rgba(43,63,81,0.75); text-shadow: 0 1px 3px rgba(255,255,255,0.5); } + .b2in-layer[data-theme="light"] .b2in-scrim-top { background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent); } + .b2in-layer[data-theme="light"] .b2in-scrim-bottom { background: linear-gradient(to top, rgba(247,248,250,0.9), transparent); } .b2in-layer[data-theme="light"] .b2in-text { background: linear-gradient(to top, rgba(247,248,250,0.85) 40%, transparent); } @@ -325,6 +348,10 @@ font-size: 24px; color: #737373; text-align: right; line-height: 1.35; font-weight: 400; } + .offer-price-note.strike { + color: #dc2626; text-decoration: line-through; + text-decoration-color: #dc2626; text-decoration-thickness: 3px; + } /* Bullets */ .offer-bullets { @@ -412,6 +439,16 @@ .status-error { color: #ef4444; font-weight: 500; } .status-sub { font-size: 1.4vh; opacity: 0.4; margin-top: 1vh; } + .display-empty { + position: absolute; inset: 0; z-index: 10; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + text-align: center; padding: 6vw; + background: #000; color: #fff; + } + .display-empty__title { font-size: 2.4vh; font-weight: 600; } + .display-empty__hint { font-size: 1.6vh; opacity: 0.5; margin-top: 1.2vh; font-weight: 300; } + .display-overview { position: fixed; inset: 0; z-index: 10000; overflow-y: auto; background: radial-gradient(circle at top, #12364d 0, #05070a 42%, #000 100%); @@ -549,6 +586,7 @@ class DisplayPlayer { this.lastSuccessTime = Date.now(); this.isRunning = false; this.activeVersionRenderer = null; + this.emptyVersionStreak = 0; // DOM this.viewport = document.getElementById('viewport'); @@ -810,6 +848,19 @@ class DisplayPlayer { return; } + // Guard against a delay-free infinite restart loop when no version in the + // playlist has any playable content (e.g. a freshly created, empty module). + if (!this.versionHasContent(version)) { + this.emptyVersionStreak++; + if (this.emptyVersionStreak >= this.playlist.length) { + this.showEmptyPlaylist(); + return; + } + this.advanceVersion(); + return; + } + this.emptyVersionStreak = 0; + console.log(`[Display] Playing version ${this.currentVersionIndex + 1}/${this.playlist.length}: ${version.version_name} (${version.type})`); // Clean up previous renderer @@ -850,6 +901,38 @@ class DisplayPlayer { this.playCurrentVersion(); } + versionHasContent(version) { + if (!version) return false; + switch (version.type) { + case 'video-display': + return (version.videoPlaylist || []).length > 0; + case 'b2in': + return (version.items || []).some(item => item.is_active); + case 'offers': + return (version.slides || []).length > 0; + default: + return false; + } + } + + showEmptyPlaylist() { + this.isRunning = false; + this.emptyVersionStreak = 0; + + if (this.activeVersionRenderer) { + this.activeVersionRenderer.destroy(); + this.activeVersionRenderer = null; + } + + this.viewport.innerHTML = ` +
+

Noch keine Inhalte vorhanden

+

Sobald Inhalte angelegt und aktiviert sind, erscheinen sie hier.

+
+ `; + console.log('[Display] Playlist has no playable content – playback stopped.'); + } + // ======================================== // UI HELPERS // ======================================== @@ -1132,7 +1215,7 @@ class B2inRenderer { build() { const layer = document.createElement('div'); - layer.className = 'version-layer b2in-layer active'; + layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : ''); layer.setAttribute('data-theme', this.theme); const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../assets/b2in-logo-positive.svg'); @@ -1145,11 +1228,57 @@ class B2inRenderer { : ''; const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu'); - layer.innerHTML = ` -
+ // Footer visibility, brand element visibility + corner positioning + const showFooter = this.settings.show_footer !== false; + const showLogo = this.settings.show_logo !== false; + const showClaim = this.settings.show_claim !== false; + const validPositions = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + let logoPos = validPositions.includes(this.settings.logo_position) ? this.settings.logo_position : 'top-left'; + let claimPos = validPositions.includes(this.settings.claim_position) ? this.settings.claim_position : 'top-right'; + + // Bottom corners only allowed when the footer is hidden + if (showFooter) { + if (logoPos.startsWith('bottom')) logoPos = logoPos.replace('bottom-', 'top-'); + if (claimPos.startsWith('bottom')) claimPos = claimPos.replace('bottom-', 'top-'); + } + // Claim must not share the logo corner + if (claimPos === logoPos) { + claimPos = (validPositions.filter(p => (showFooter ? p.startsWith('top') : true) && p !== logoPos)[0]) || claimPos; + } + + const logoVisible = showLogo; + const claimVisible = showClaim && !!headerClaim; + + const hasTop = (logoVisible && logoPos.startsWith('top')) || (claimVisible && claimPos.startsWith('top')); + const hasBottom = (logoVisible && logoPos.startsWith('bottom')) || (claimVisible && claimPos.startsWith('bottom')); + + const logoHtml = logoVisible + ? `
+ ` + : ''; + + const claimHtml = claimVisible + ? `
${escapeHtml(headerClaim)}
` + : ''; + + const footerHtml = showFooter + ? `
+
+ ${escapeHtml(footerUrl)} + ${footerNameHtml} +
+ +
` + : ''; + + layer.innerHTML = ` + ${hasTop ? '
' : ''} + ${(hasBottom && !showFooter) ? '
' : ''} + ${logoHtml} + ${claimHtml}
@@ -1158,15 +1287,7 @@ class B2inRenderer {
-
-
- ${escapeHtml(footerUrl)} - ${footerNameHtml} -
- -
+ ${footerHtml}
@@ -1375,42 +1496,64 @@ class OffersRenderer { } buildSlide(slide) { + // Single dynamic detail layout: every block is toggleable. Older slides + // without explicit show_* flags fall back to "is there content?". + const has = v => v !== undefined && v !== null && v !== ''; + const qrUrl = slide.qr_url || this.settings.footer_url || ''; + const contactText = slide.contact || this.settings.footer_claim || ''; + const show = { + logo: slide.show_logo ?? true, + badge: (slide.show_badge ?? has(slide.badge_text)) && has(slide.badge_text), + eyebrow: (slide.show_eyebrow ?? has(slide.eyebrow)) && has(slide.eyebrow), + subline: (slide.show_subline ?? has(slide.subline)) && has(slide.subline), + bullets: (slide.show_bullets ?? (slide.bullets && slide.bullets.length > 0)) && (slide.bullets && slide.bullets.length > 0), + price: (slide.show_price ?? has(slide.price)) && has(slide.price), + disclaimer: (slide.show_disclaimer ?? has(slide.disclaimer)) && has(slide.disclaimer), + qr: (slide.show_qr ?? has(slide.qr_url)) && has(qrUrl), + contact: (slide.show_contact ?? has(slide.contact)) && has(contactText), + }; + const wrapper = document.createElement('div'); wrapper.className = 'offers-slide-container'; const article = document.createElement('article'); article.className = 'offer-slide'; - // --- HEADER --- - const header = document.createElement('header'); - header.className = 'offer-header'; + // --- HEADER (Logo & Marke) – per slide, toggleable --- + if (show.logo) { + const header = document.createElement('header'); + header.className = 'offer-header'; - const brand = document.createElement('div'); - brand.className = 'offer-brand'; - const brandLogo = document.createElement('img'); - brandLogo.src = this.resolveUrl(this.settings.logo_url || '../logo-cabinet-300.png'); - brandLogo.alt = 'CABINET'; - brandLogo.className = 'offer-brand-logo'; - brand.appendChild(brandLogo); + const brand = document.createElement('div'); + brand.className = 'offer-brand'; + const brandLogo = document.createElement('img'); + brandLogo.src = this.resolveUrl(slide.logo_url || '../logo-cabinet-300.png'); + brandLogo.alt = 'Logo'; + brandLogo.className = 'offer-brand-logo'; + brand.appendChild(brandLogo); - if (slide.show_brand_text) { - const brandText = document.createElement('span'); - brandText.className = 'offer-brand-text'; - brandText.textContent = this.settings.brand_text || 'Bielefeld'; - brand.appendChild(brandText); + if (has(slide.brand_text)) { + const brandText = document.createElement('span'); + brandText.className = 'offer-brand-text'; + brandText.textContent = slide.brand_text; + brand.appendChild(brandText); + } + + header.appendChild(brand); + + if (has(slide.brand_tagline)) { + const tagline = document.createElement('div'); + tagline.className = 'offer-tagline'; + tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '
'); + header.appendChild(tagline); + } + + article.appendChild(header); + } else { + // Without the header row, let the hero stay the flexible middle row. + article.style.gridTemplateRows = '1fr auto'; } - header.appendChild(brand); - - if (slide.show_brand_text && slide.brand_tagline) { - const tagline = document.createElement('div'); - tagline.className = 'offer-tagline'; - tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '
'); - header.appendChild(tagline); - } - - article.appendChild(header); - // --- HERO --- const hero = document.createElement('section'); hero.className = 'offer-hero'; @@ -1419,7 +1562,7 @@ class OffersRenderer { hero.style.background = `url('${imgUrl}') center/cover no-repeat`; } - if (slide.badge_text) { + if (show.badge) { const badge = document.createElement('span'); badge.className = 'offer-hero-badge large'; badge.textContent = slide.badge_text; @@ -1439,7 +1582,7 @@ class OffersRenderer { const infoContent = document.createElement('div'); infoContent.className = 'offer-info-content'; - if (slide.eyebrow) { + if (show.eyebrow) { const eyebrow = document.createElement('p'); eyebrow.className = 'offer-eyebrow'; eyebrow.textContent = slide.eyebrow; @@ -1448,22 +1591,19 @@ class OffersRenderer { if (slide.title) { const title = document.createElement('h1'); - const titleSize = (slide.type === 'product-details') ? 'medium' : 'large'; - title.className = `offer-title ${titleSize}`; + title.className = 'offer-title medium'; title.innerHTML = slide.title.replace(/\n/g, '
'); infoContent.appendChild(title); } - // Subline (product-impulse) - if (slide.subline) { + if (show.subline) { const subline = document.createElement('p'); subline.className = 'offer-subline'; subline.textContent = slide.subline; infoContent.appendChild(subline); } - // Bullets (product-details) - if (slide.bullets && slide.bullets.length > 0) { + if (show.bullets) { const ul = document.createElement('ul'); ul.className = 'offer-bullets'; slide.bullets.forEach(text => { @@ -1477,8 +1617,8 @@ class OffersRenderer { info.appendChild(infoContent); - // Price block (product-hero, product-impulse) - if (slide.price) { + // Price block + if (show.price) { const priceBlock = document.createElement('div'); priceBlock.className = 'offer-price-block'; @@ -1492,7 +1632,7 @@ class OffersRenderer { if (slide.original_price) { const note = document.createElement('div'); - note.className = 'offer-price-note'; + note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note'; note.textContent = slide.original_price; priceRow.appendChild(note); } @@ -1511,8 +1651,8 @@ class OffersRenderer { info.appendChild(priceBlock); } - // Disclaimer (intro) - if (slide.disclaimer) { + // Disclaimer + if (show.disclaimer) { const footer = document.createElement('div'); footer.className = 'offer-info-footer'; const disc = document.createElement('span'); @@ -1524,38 +1664,41 @@ class OffersRenderer { bottom.appendChild(info); - // QR Box - const qrBox = document.createElement('aside'); - qrBox.className = 'offer-qr-box'; + // QR / Contact box – only when at least one of the two is enabled. + if (show.qr || show.contact) { + const qrBox = document.createElement('aside'); + qrBox.className = 'offer-qr-box'; - const qrHeader = document.createElement('div'); - qrHeader.className = 'offer-qr-header'; - qrHeader.innerHTML = ` -

${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}

-

${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}

- `; - qrBox.appendChild(qrHeader); + if (show.qr) { + const qrHeader = document.createElement('div'); + qrHeader.className = 'offer-qr-header'; + qrHeader.innerHTML = ` +

${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}

+

${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}

+ `; + qrBox.appendChild(qrHeader); - const qrWrapper = document.createElement('div'); - qrWrapper.className = 'offer-qr-wrapper'; - const qrUrl = slide.qr_url || this.settings.footer_url || ''; - if (qrUrl) { - const qrImg = document.createElement('img'); - qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`; - qrImg.alt = 'QR Code'; - qrWrapper.appendChild(qrImg); - } - qrBox.appendChild(qrWrapper); + const qrWrapper = document.createElement('div'); + qrWrapper.className = 'offer-qr-wrapper'; + const qrImg = document.createElement('img'); + qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`; + qrImg.alt = 'QR Code'; + qrWrapper.appendChild(qrImg); + qrBox.appendChild(qrWrapper); + } - const contactText = slide.contact || this.settings.footer_claim || ''; - if (contactText) { - const contact = document.createElement('p'); - contact.className = 'offer-qr-contact'; - contact.innerHTML = contactText.replace(/\n/g, '
'); - qrBox.appendChild(contact); + if (show.contact) { + const contact = document.createElement('p'); + contact.className = 'offer-qr-contact'; + contact.innerHTML = contactText.replace(/\n/g, '
'); + qrBox.appendChild(contact); + } + + bottom.appendChild(qrBox); + } else { + bottom.style.gridTemplateColumns = '1fr'; } - bottom.appendChild(qrBox); article.appendChild(bottom); wrapper.appendChild(article); diff --git a/public/flux/flux.js b/public/flux/flux.js deleted file mode 100644 index f59722a..0000000 --- a/public/flux/flux.js +++ /dev/null @@ -1,13171 +0,0 @@ -(() => { - // node_modules/@oddbird/popover-polyfill/dist/popover.js - var ToggleEvent = class extends Event { - oldState; - newState; - constructor(type, { oldState = "", newState = "", ...init } = {}) { - super(type, init); - this.oldState = String(oldState || ""); - this.newState = String(newState || ""); - } - }; - var popoverToggleTaskQueue = /* @__PURE__ */ new WeakMap(); - function queuePopoverToggleEventTask(element2, oldState, newState) { - popoverToggleTaskQueue.set( - element2, - setTimeout(() => { - if (!popoverToggleTaskQueue.has(element2)) return; - element2.dispatchEvent( - new ToggleEvent("toggle", { - cancelable: false, - oldState, - newState - }) - ); - }, 0) - ); - } - var ShadowRoot2 = globalThis.ShadowRoot || function() { - }; - var HTMLDialogElement = globalThis.HTMLDialogElement || function() { - }; - var topLayerElements = /* @__PURE__ */ new WeakMap(); - var autoPopoverList = /* @__PURE__ */ new WeakMap(); - var visibilityState = /* @__PURE__ */ new WeakMap(); - function getPopoverVisibilityState(popover) { - return visibilityState.get(popover) || "hidden"; - } - var popoverInvoker = /* @__PURE__ */ new WeakMap(); - function popoverTargetAttributeActivationBehavior(element2) { - const popover = element2.popoverTargetElement; - if (!(popover instanceof HTMLElement)) { - return; - } - const visibility = getPopoverVisibilityState(popover); - if (element2.popoverTargetAction === "show" && visibility === "showing") { - return; - } - if (element2.popoverTargetAction === "hide" && visibility === "hidden") return; - if (visibility === "showing") { - hidePopover(popover, true, true); - } else if (checkPopoverValidity(popover, false)) { - popoverInvoker.set(popover, element2); - showPopover(popover); - } - } - function checkPopoverValidity(element2, expectedToBeShowing) { - if (element2.popover !== "auto" && element2.popover !== "manual") { - return false; - } - if (!element2.isConnected) return false; - if (expectedToBeShowing && getPopoverVisibilityState(element2) !== "showing") { - return false; - } - if (!expectedToBeShowing && getPopoverVisibilityState(element2) !== "hidden") { - return false; - } - if (element2 instanceof HTMLDialogElement && element2.hasAttribute("open")) { - return false; - } - if (document.fullscreenElement === element2) return false; - return true; - } - function getStackPosition(popover) { - if (!popover) return 0; - return Array.from(autoPopoverList.get(popover.ownerDocument) || []).indexOf( - popover - ) + 1; - } - function topMostClickedPopover(target) { - const clickedPopover = nearestInclusiveOpenPopover(target); - const invokerPopover = nearestInclusiveTargetPopoverForInvoker(target); - if (getStackPosition(clickedPopover) > getStackPosition(invokerPopover)) { - return clickedPopover; - } - return invokerPopover; - } - function topMostAutoPopover(document2) { - const documentPopovers = autoPopoverList.get(document2); - for (const popover of documentPopovers || []) { - if (!popover.isConnected) { - documentPopovers.delete(popover); - } else { - return popover; - } - } - return null; - } - function getRootNode(node) { - if (typeof node.getRootNode === "function") { - return node.getRootNode(); - } - if (node.parentNode) return getRootNode(node.parentNode); - return node; - } - function nearestInclusiveOpenPopover(node) { - while (node) { - if (node instanceof HTMLElement && node.popover === "auto" && visibilityState.get(node) === "showing") { - return node; - } - node = node instanceof Element && node.assignedSlot || node.parentElement || getRootNode(node); - if (node instanceof ShadowRoot2) node = node.host; - if (node instanceof Document) return; - } - } - function nearestInclusiveTargetPopoverForInvoker(node) { - while (node) { - const nodePopover = node.popoverTargetElement; - if (nodePopover instanceof HTMLElement) return nodePopover; - node = node.parentElement || getRootNode(node); - if (node instanceof ShadowRoot2) node = node.host; - if (node instanceof Document) return; - } - } - function topMostPopoverAncestor(newPopover) { - const popoverPositions = /* @__PURE__ */ new Map(); - let i = 0; - for (const popover of autoPopoverList.get(newPopover.ownerDocument) || []) { - popoverPositions.set(popover, i); - i += 1; - } - popoverPositions.set(newPopover, i); - i += 1; - let topMostPopoverAncestor22 = null; - function checkAncestor(candidate) { - const candidateAncestor = nearestInclusiveOpenPopover(candidate); - if (candidateAncestor === null) return null; - const candidatePosition = popoverPositions.get(candidateAncestor); - if (topMostPopoverAncestor22 === null || popoverPositions.get(topMostPopoverAncestor22) < candidatePosition) { - topMostPopoverAncestor22 = candidateAncestor; - } - } - checkAncestor(newPopover.parentElement || getRootNode(newPopover)); - return topMostPopoverAncestor22; - } - function isFocusable(focusTarget) { - if (focusTarget.hidden || focusTarget instanceof ShadowRoot2) return false; - if (focusTarget instanceof HTMLButtonElement || focusTarget instanceof HTMLInputElement || focusTarget instanceof HTMLSelectElement || focusTarget instanceof HTMLTextAreaElement || focusTarget instanceof HTMLOptGroupElement || focusTarget instanceof HTMLOptionElement || focusTarget instanceof HTMLFieldSetElement) { - if (focusTarget.disabled) return false; - } - if (focusTarget instanceof HTMLInputElement && focusTarget.type === "hidden") { - return false; - } - if (focusTarget instanceof HTMLAnchorElement && focusTarget.href === "") { - return false; - } - return typeof focusTarget.tabIndex === "number" && focusTarget.tabIndex !== -1; - } - function focusDelegate(focusTarget) { - if (focusTarget.shadowRoot && focusTarget.shadowRoot.delegatesFocus !== true) { - return null; - } - let whereToLook = focusTarget; - if (whereToLook.shadowRoot) { - whereToLook = whereToLook.shadowRoot; - } - let autoFocusDelegate = whereToLook.querySelector("[autofocus]"); - if (autoFocusDelegate) { - return autoFocusDelegate; - } else { - const slots = whereToLook.querySelectorAll("slot"); - for (const slot of slots) { - const assignedElements = slot.assignedElements({ flatten: true }); - for (const el of assignedElements) { - if (el.hasAttribute("autofocus")) { - return el; - } else { - autoFocusDelegate = el.querySelector("[autofocus]"); - if (autoFocusDelegate) { - return autoFocusDelegate; - } - } - } - } - } - const walker2 = focusTarget.ownerDocument.createTreeWalker( - whereToLook, - NodeFilter.SHOW_ELEMENT - ); - let descendant = walker2.currentNode; - while (descendant) { - if (isFocusable(descendant)) { - return descendant; - } - descendant = walker2.nextNode(); - } - } - function popoverFocusingSteps(subject) { - focusDelegate(subject)?.focus(); - } - var previouslyFocusedElements = /* @__PURE__ */ new WeakMap(); - function showPopover(element2) { - if (!checkPopoverValidity(element2, false)) { - return; - } - const document2 = element2.ownerDocument; - if (!element2.dispatchEvent( - new ToggleEvent("beforetoggle", { - cancelable: true, - oldState: "closed", - newState: "open" - }) - )) { - return; - } - if (!checkPopoverValidity(element2, false)) { - return; - } - let shouldRestoreFocus = false; - if (element2.popover === "auto") { - const originalType = element2.getAttribute("popover"); - const ancestor = topMostPopoverAncestor(element2) || document2; - hideAllPopoversUntil(ancestor, false, true); - if (originalType !== element2.getAttribute("popover") || !checkPopoverValidity(element2, false)) { - return; - } - } - if (!topMostAutoPopover(document2)) { - shouldRestoreFocus = true; - } - previouslyFocusedElements.delete(element2); - const originallyFocusedElement = document2.activeElement; - element2.classList.add(":popover-open"); - visibilityState.set(element2, "showing"); - if (!topLayerElements.has(document2)) { - topLayerElements.set(document2, /* @__PURE__ */ new Set()); - } - topLayerElements.get(document2).add(element2); - popoverFocusingSteps(element2); - if (element2.popover === "auto") { - if (!autoPopoverList.has(document2)) { - autoPopoverList.set(document2, /* @__PURE__ */ new Set()); - } - autoPopoverList.get(document2).add(element2); - setInvokerAriaExpanded(popoverInvoker.get(element2), true); - } - if (shouldRestoreFocus && originallyFocusedElement && element2.popover === "auto") { - previouslyFocusedElements.set(element2, originallyFocusedElement); - } - queuePopoverToggleEventTask(element2, "closed", "open"); - } - function hidePopover(element2, focusPreviousElement = false, fireEvents = false) { - if (!checkPopoverValidity(element2, true)) { - return; - } - const document2 = element2.ownerDocument; - if (element2.popover === "auto") { - hideAllPopoversUntil(element2, focusPreviousElement, fireEvents); - if (!checkPopoverValidity(element2, true)) { - return; - } - } - setInvokerAriaExpanded(popoverInvoker.get(element2), false); - popoverInvoker.delete(element2); - if (fireEvents) { - element2.dispatchEvent( - new ToggleEvent("beforetoggle", { - oldState: "open", - newState: "closed" - }) - ); - if (!checkPopoverValidity(element2, true)) { - return; - } - } - topLayerElements.get(document2)?.delete(element2); - autoPopoverList.get(document2)?.delete(element2); - element2.classList.remove(":popover-open"); - visibilityState.set(element2, "hidden"); - if (fireEvents) { - queuePopoverToggleEventTask(element2, "open", "closed"); - } - const previouslyFocusedElement = previouslyFocusedElements.get(element2); - if (previouslyFocusedElement) { - previouslyFocusedElements.delete(element2); - if (focusPreviousElement) { - previouslyFocusedElement.focus(); - } - } - } - function closeAllOpenPopovers(document2, focusPreviousElement = false, fireEvents = false) { - let popover = topMostAutoPopover(document2); - while (popover) { - hidePopover(popover, focusPreviousElement, fireEvents); - popover = topMostAutoPopover(document2); - } - } - function hideAllPopoversUntil(endpoint, focusPreviousElement, fireEvents) { - const document2 = endpoint.ownerDocument || endpoint; - if (endpoint instanceof Document) { - return closeAllOpenPopovers(document2, focusPreviousElement, fireEvents); - } - let lastToHide = null; - let foundEndpoint = false; - for (const popover of autoPopoverList.get(document2) || []) { - if (popover === endpoint) { - foundEndpoint = true; - } else if (foundEndpoint) { - lastToHide = popover; - break; - } - } - if (!foundEndpoint) { - return closeAllOpenPopovers(document2, focusPreviousElement, fireEvents); - } - while (lastToHide && getPopoverVisibilityState(lastToHide) === "showing" && autoPopoverList.get(document2)?.size) { - hidePopover(lastToHide, focusPreviousElement, fireEvents); - } - } - var popoverPointerDownTargets = /* @__PURE__ */ new WeakMap(); - function lightDismissOpenPopovers(event) { - if (!event.isTrusted) return; - const target = event.composedPath()[0]; - if (!target) return; - const document2 = target.ownerDocument; - const topMostPopover = topMostAutoPopover(document2); - if (!topMostPopover) return; - const ancestor = topMostClickedPopover(target); - if (ancestor && event.type === "pointerdown") { - popoverPointerDownTargets.set(document2, ancestor); - } else if (event.type === "pointerup") { - const sameTarget = popoverPointerDownTargets.get(document2) === ancestor; - popoverPointerDownTargets.delete(document2); - if (sameTarget) { - hideAllPopoversUntil(ancestor || document2, false, true); - } - } - } - var initialAriaExpandedValue = /* @__PURE__ */ new WeakMap(); - function setInvokerAriaExpanded(el, force = false) { - if (!el) return; - if (!initialAriaExpandedValue.has(el)) { - initialAriaExpandedValue.set(el, el.getAttribute("aria-expanded")); - } - const popover = el.popoverTargetElement; - if (popover instanceof HTMLElement && popover.popover === "auto") { - el.setAttribute("aria-expanded", String(force)); - } else { - const initialValue = initialAriaExpandedValue.get(el); - if (!initialValue) { - el.removeAttribute("aria-expanded"); - } else { - el.setAttribute("aria-expanded", initialValue); - } - } - } - var ShadowRoot22 = globalThis.ShadowRoot || function() { - }; - function isSupported() { - return typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype === "object" && "popover" in HTMLElement.prototype; - } - function patchSelectorFn(object, name, mapper) { - const original = object[name]; - Object.defineProperty(object, name, { - value(selector) { - return original.call(this, mapper(selector)); - } - }); - } - var nonEscapedPopoverSelector = /(^|[^\\]):popover-open\b/g; - function hasLayerSupport() { - return typeof globalThis.CSSLayerBlockRule === "function"; - } - function getStyles() { - const useLayer = hasLayerSupport(); - return ` -${useLayer ? "@layer popover-polyfill {" : ""} - :where([popover]) { - position: fixed; - z-index: 2147483647; - inset: 0; - padding: 0.25em; - width: fit-content; - height: fit-content; - border-width: initial; - border-color: initial; - border-image: initial; - border-style: solid; - background-color: canvas; - color: canvastext; - overflow: auto; - margin: auto; - } - - :where([popover]:not(.\\:popover-open)) { - display: none; - } - - :where(dialog[popover].\\:popover-open) { - display: block; - } - - :where(dialog[popover][open]) { - display: revert; - } - - :where([anchor].\\:popover-open) { - inset: auto; - } - - :where([anchor]:popover-open) { - inset: auto; - } - - @supports not (background-color: canvas) { - :where([popover]) { - background-color: white; - color: black; - } - } - - @supports (width: -moz-fit-content) { - :where([popover]) { - width: -moz-fit-content; - height: -moz-fit-content; - } - } - - @supports not (inset: 0) { - :where([popover]) { - top: 0; - left: 0; - right: 0; - bottom: 0; - } - } -${useLayer ? "}" : ""} -`; - } - var popoverStyleSheet = null; - function injectStyles(root) { - const styles = getStyles(); - if (popoverStyleSheet === null) { - try { - popoverStyleSheet = new CSSStyleSheet(); - popoverStyleSheet.replaceSync(styles); - } catch { - popoverStyleSheet = false; - } - } - if (popoverStyleSheet === false) { - const sheet = document.createElement("style"); - sheet.textContent = styles; - if (root instanceof Document) { - root.head.prepend(sheet); - } else { - root.prepend(sheet); - } - } else { - root.adoptedStyleSheets = [popoverStyleSheet, ...root.adoptedStyleSheets]; - } - } - function apply() { - if (typeof window === "undefined") return; - window.ToggleEvent = window.ToggleEvent || ToggleEvent; - function rewriteSelector(selector) { - if (selector?.includes(":popover-open")) { - selector = selector.replace( - nonEscapedPopoverSelector, - "$1.\\:popover-open" - ); - } - return selector; - } - patchSelectorFn(Document.prototype, "querySelector", rewriteSelector); - patchSelectorFn(Document.prototype, "querySelectorAll", rewriteSelector); - patchSelectorFn(Element.prototype, "querySelector", rewriteSelector); - patchSelectorFn(Element.prototype, "querySelectorAll", rewriteSelector); - patchSelectorFn(Element.prototype, "matches", rewriteSelector); - patchSelectorFn(Element.prototype, "closest", rewriteSelector); - patchSelectorFn( - DocumentFragment.prototype, - "querySelectorAll", - rewriteSelector - ); - Object.defineProperties(HTMLElement.prototype, { - popover: { - enumerable: true, - configurable: true, - get() { - if (!this.hasAttribute("popover")) return null; - const value3 = (this.getAttribute("popover") || "").toLowerCase(); - if (value3 === "" || value3 == "auto") return "auto"; - return "manual"; - }, - set(value3) { - if (value3 === null) { - this.removeAttribute("popover"); - } else { - this.setAttribute("popover", value3); - } - } - }, - showPopover: { - enumerable: true, - configurable: true, - value() { - showPopover(this); - } - }, - hidePopover: { - enumerable: true, - configurable: true, - value() { - hidePopover(this, true, true); - } - }, - togglePopover: { - enumerable: true, - configurable: true, - value(force) { - if (visibilityState.get(this) === "showing" && force === void 0 || force === false) { - hidePopover(this, true, true); - } else if (force === void 0 || force === true) { - showPopover(this); - } - } - } - }); - const originalAttachShadow = Element.prototype.attachShadow; - if (originalAttachShadow) { - Object.defineProperties(Element.prototype, { - attachShadow: { - enumerable: true, - configurable: true, - writable: true, - value(options) { - const shadowRoot = originalAttachShadow.call(this, options); - injectStyles(shadowRoot); - return shadowRoot; - } - } - }); - } - const originalAttachInternals = HTMLElement.prototype.attachInternals; - if (originalAttachInternals) { - Object.defineProperties(HTMLElement.prototype, { - attachInternals: { - enumerable: true, - configurable: true, - writable: true, - value() { - const internals = originalAttachInternals.call(this); - if (internals.shadowRoot) { - injectStyles(internals.shadowRoot); - } - return internals; - } - } - }); - } - const popoverTargetAssociatedElements = /* @__PURE__ */ new WeakMap(); - function applyPopoverInvokerElementMixin(ElementClass) { - Object.defineProperties(ElementClass.prototype, { - popoverTargetElement: { - enumerable: true, - configurable: true, - set(targetElement) { - if (targetElement === null) { - this.removeAttribute("popovertarget"); - popoverTargetAssociatedElements.delete(this); - } else if (!(targetElement instanceof Element)) { - throw new TypeError( - `popoverTargetElement must be an element or null` - ); - } else { - this.setAttribute("popovertarget", ""); - popoverTargetAssociatedElements.set(this, targetElement); - } - }, - get() { - if (this.localName !== "button" && this.localName !== "input") { - return null; - } - if (this.localName === "input" && this.type !== "reset" && this.type !== "image" && this.type !== "button") { - return null; - } - if (this.disabled) { - return null; - } - if (this.form && this.type === "submit") { - return null; - } - const targetElement = popoverTargetAssociatedElements.get(this); - if (targetElement && targetElement.isConnected) { - return targetElement; - } else if (targetElement && !targetElement.isConnected) { - popoverTargetAssociatedElements.delete(this); - return null; - } - const root = getRootNode(this); - const idref = this.getAttribute("popovertarget"); - if ((root instanceof Document || root instanceof ShadowRoot22) && idref) { - return root.getElementById(idref) || null; - } - return null; - } - }, - popoverTargetAction: { - enumerable: true, - configurable: true, - get() { - const value3 = (this.getAttribute("popovertargetaction") || "").toLowerCase(); - if (value3 === "show" || value3 === "hide") return value3; - return "toggle"; - }, - set(value3) { - this.setAttribute("popovertargetaction", value3); - } - } - }); - } - applyPopoverInvokerElementMixin(HTMLButtonElement); - applyPopoverInvokerElementMixin(HTMLInputElement); - const handleInvokerActivation = (event) => { - const composedPath = event.composedPath(); - const target = composedPath[0]; - if (!(target instanceof Element) || target?.shadowRoot) { - return; - } - const root = getRootNode(target); - if (!(root instanceof ShadowRoot22 || root instanceof Document)) { - return; - } - const invoker = composedPath.find( - (el) => el.matches?.("[popovertargetaction],[popovertarget]") - ); - if (invoker) { - popoverTargetAttributeActivationBehavior(invoker); - event.preventDefault(); - return; - } - }; - const onKeydown = (event) => { - const key = event.key; - const target = event.target; - if (!event.defaultPrevented && target && (key === "Escape" || key === "Esc")) { - hideAllPopoversUntil(target.ownerDocument, true, true); - } - }; - const addEventListeners = (root) => { - root.addEventListener("click", handleInvokerActivation); - root.addEventListener("keydown", onKeydown); - root.addEventListener("pointerdown", lightDismissOpenPopovers); - root.addEventListener("pointerup", lightDismissOpenPopovers); - }; - addEventListeners(document); - injectStyles(document); - } - if (!isSupported()) apply(); - - // node_modules/@oddbird/popover-polyfill/dist/popover-fn.js - var ToggleEvent2 = class extends Event { - oldState; - newState; - constructor(type, { oldState = "", newState = "", ...init } = {}) { - super(type, init); - this.oldState = String(oldState || ""); - this.newState = String(newState || ""); - } - }; - var popoverToggleTaskQueue2 = /* @__PURE__ */ new WeakMap(); - function queuePopoverToggleEventTask2(element2, oldState, newState) { - popoverToggleTaskQueue2.set( - element2, - setTimeout(() => { - if (!popoverToggleTaskQueue2.has(element2)) return; - element2.dispatchEvent( - new ToggleEvent2("toggle", { - cancelable: false, - oldState, - newState - }) - ); - }, 0) - ); - } - var ShadowRoot3 = globalThis.ShadowRoot || function() { - }; - var HTMLDialogElement2 = globalThis.HTMLDialogElement || function() { - }; - var topLayerElements2 = /* @__PURE__ */ new WeakMap(); - var autoPopoverList2 = /* @__PURE__ */ new WeakMap(); - var visibilityState2 = /* @__PURE__ */ new WeakMap(); - function getPopoverVisibilityState2(popover) { - return visibilityState2.get(popover) || "hidden"; - } - var popoverInvoker2 = /* @__PURE__ */ new WeakMap(); - function popoverTargetAttributeActivationBehavior2(element2) { - const popover = element2.popoverTargetElement; - if (!(popover instanceof HTMLElement)) { - return; - } - const visibility = getPopoverVisibilityState2(popover); - if (element2.popoverTargetAction === "show" && visibility === "showing") { - return; - } - if (element2.popoverTargetAction === "hide" && visibility === "hidden") return; - if (visibility === "showing") { - hidePopover2(popover, true, true); - } else if (checkPopoverValidity2(popover, false)) { - popoverInvoker2.set(popover, element2); - showPopover2(popover); - } - } - function checkPopoverValidity2(element2, expectedToBeShowing) { - if (element2.popover !== "auto" && element2.popover !== "manual") { - return false; - } - if (!element2.isConnected) return false; - if (expectedToBeShowing && getPopoverVisibilityState2(element2) !== "showing") { - return false; - } - if (!expectedToBeShowing && getPopoverVisibilityState2(element2) !== "hidden") { - return false; - } - if (element2 instanceof HTMLDialogElement2 && element2.hasAttribute("open")) { - return false; - } - if (document.fullscreenElement === element2) return false; - return true; - } - function getStackPosition2(popover) { - if (!popover) return 0; - return Array.from(autoPopoverList2.get(popover.ownerDocument) || []).indexOf( - popover - ) + 1; - } - function topMostClickedPopover2(target) { - const clickedPopover = nearestInclusiveOpenPopover2(target); - const invokerPopover = nearestInclusiveTargetPopoverForInvoker2(target); - if (getStackPosition2(clickedPopover) > getStackPosition2(invokerPopover)) { - return clickedPopover; - } - return invokerPopover; - } - function topMostAutoPopover2(document2) { - const documentPopovers = autoPopoverList2.get(document2); - for (const popover of documentPopovers || []) { - if (!popover.isConnected) { - documentPopovers.delete(popover); - } else { - return popover; - } - } - return null; - } - function getRootNode2(node) { - if (typeof node.getRootNode === "function") { - return node.getRootNode(); - } - if (node.parentNode) return getRootNode2(node.parentNode); - return node; - } - function nearestInclusiveOpenPopover2(node) { - while (node) { - if (node instanceof HTMLElement && node.popover === "auto" && visibilityState2.get(node) === "showing") { - return node; - } - node = node instanceof Element && node.assignedSlot || node.parentElement || getRootNode2(node); - if (node instanceof ShadowRoot3) node = node.host; - if (node instanceof Document) return; - } - } - function nearestInclusiveTargetPopoverForInvoker2(node) { - while (node) { - const nodePopover = node.popoverTargetElement; - if (nodePopover instanceof HTMLElement) return nodePopover; - node = node.parentElement || getRootNode2(node); - if (node instanceof ShadowRoot3) node = node.host; - if (node instanceof Document) return; - } - } - function topMostPopoverAncestor2(newPopover) { - const popoverPositions = /* @__PURE__ */ new Map(); - let i = 0; - for (const popover of autoPopoverList2.get(newPopover.ownerDocument) || []) { - popoverPositions.set(popover, i); - i += 1; - } - popoverPositions.set(newPopover, i); - i += 1; - let topMostPopoverAncestor22 = null; - function checkAncestor(candidate) { - const candidateAncestor = nearestInclusiveOpenPopover2(candidate); - if (candidateAncestor === null) return null; - const candidatePosition = popoverPositions.get(candidateAncestor); - if (topMostPopoverAncestor22 === null || popoverPositions.get(topMostPopoverAncestor22) < candidatePosition) { - topMostPopoverAncestor22 = candidateAncestor; - } - } - checkAncestor(newPopover.parentElement || getRootNode2(newPopover)); - return topMostPopoverAncestor22; - } - function isFocusable2(focusTarget) { - if (focusTarget.hidden || focusTarget instanceof ShadowRoot3) return false; - if (focusTarget instanceof HTMLButtonElement || focusTarget instanceof HTMLInputElement || focusTarget instanceof HTMLSelectElement || focusTarget instanceof HTMLTextAreaElement || focusTarget instanceof HTMLOptGroupElement || focusTarget instanceof HTMLOptionElement || focusTarget instanceof HTMLFieldSetElement) { - if (focusTarget.disabled) return false; - } - if (focusTarget instanceof HTMLInputElement && focusTarget.type === "hidden") { - return false; - } - if (focusTarget instanceof HTMLAnchorElement && focusTarget.href === "") { - return false; - } - return typeof focusTarget.tabIndex === "number" && focusTarget.tabIndex !== -1; - } - function focusDelegate2(focusTarget) { - if (focusTarget.shadowRoot && focusTarget.shadowRoot.delegatesFocus !== true) { - return null; - } - let whereToLook = focusTarget; - if (whereToLook.shadowRoot) { - whereToLook = whereToLook.shadowRoot; - } - let autoFocusDelegate = whereToLook.querySelector("[autofocus]"); - if (autoFocusDelegate) { - return autoFocusDelegate; - } else { - const slots = whereToLook.querySelectorAll("slot"); - for (const slot of slots) { - const assignedElements = slot.assignedElements({ flatten: true }); - for (const el of assignedElements) { - if (el.hasAttribute("autofocus")) { - return el; - } else { - autoFocusDelegate = el.querySelector("[autofocus]"); - if (autoFocusDelegate) { - return autoFocusDelegate; - } - } - } - } - } - const walker2 = focusTarget.ownerDocument.createTreeWalker( - whereToLook, - NodeFilter.SHOW_ELEMENT - ); - let descendant = walker2.currentNode; - while (descendant) { - if (isFocusable2(descendant)) { - return descendant; - } - descendant = walker2.nextNode(); - } - } - function popoverFocusingSteps2(subject) { - focusDelegate2(subject)?.focus(); - } - var previouslyFocusedElements2 = /* @__PURE__ */ new WeakMap(); - function showPopover2(element2) { - if (!checkPopoverValidity2(element2, false)) { - return; - } - const document2 = element2.ownerDocument; - if (!element2.dispatchEvent( - new ToggleEvent2("beforetoggle", { - cancelable: true, - oldState: "closed", - newState: "open" - }) - )) { - return; - } - if (!checkPopoverValidity2(element2, false)) { - return; - } - let shouldRestoreFocus = false; - if (element2.popover === "auto") { - const originalType = element2.getAttribute("popover"); - const ancestor = topMostPopoverAncestor2(element2) || document2; - hideAllPopoversUntil2(ancestor, false, true); - if (originalType !== element2.getAttribute("popover") || !checkPopoverValidity2(element2, false)) { - return; - } - } - if (!topMostAutoPopover2(document2)) { - shouldRestoreFocus = true; - } - previouslyFocusedElements2.delete(element2); - const originallyFocusedElement = document2.activeElement; - element2.classList.add(":popover-open"); - visibilityState2.set(element2, "showing"); - if (!topLayerElements2.has(document2)) { - topLayerElements2.set(document2, /* @__PURE__ */ new Set()); - } - topLayerElements2.get(document2).add(element2); - popoverFocusingSteps2(element2); - if (element2.popover === "auto") { - if (!autoPopoverList2.has(document2)) { - autoPopoverList2.set(document2, /* @__PURE__ */ new Set()); - } - autoPopoverList2.get(document2).add(element2); - setInvokerAriaExpanded2(popoverInvoker2.get(element2), true); - } - if (shouldRestoreFocus && originallyFocusedElement && element2.popover === "auto") { - previouslyFocusedElements2.set(element2, originallyFocusedElement); - } - queuePopoverToggleEventTask2(element2, "closed", "open"); - } - function hidePopover2(element2, focusPreviousElement = false, fireEvents = false) { - if (!checkPopoverValidity2(element2, true)) { - return; - } - const document2 = element2.ownerDocument; - if (element2.popover === "auto") { - hideAllPopoversUntil2(element2, focusPreviousElement, fireEvents); - if (!checkPopoverValidity2(element2, true)) { - return; - } - } - setInvokerAriaExpanded2(popoverInvoker2.get(element2), false); - popoverInvoker2.delete(element2); - if (fireEvents) { - element2.dispatchEvent( - new ToggleEvent2("beforetoggle", { - oldState: "open", - newState: "closed" - }) - ); - if (!checkPopoverValidity2(element2, true)) { - return; - } - } - topLayerElements2.get(document2)?.delete(element2); - autoPopoverList2.get(document2)?.delete(element2); - element2.classList.remove(":popover-open"); - visibilityState2.set(element2, "hidden"); - if (fireEvents) { - queuePopoverToggleEventTask2(element2, "open", "closed"); - } - const previouslyFocusedElement = previouslyFocusedElements2.get(element2); - if (previouslyFocusedElement) { - previouslyFocusedElements2.delete(element2); - if (focusPreviousElement) { - previouslyFocusedElement.focus(); - } - } - } - function closeAllOpenPopovers2(document2, focusPreviousElement = false, fireEvents = false) { - let popover = topMostAutoPopover2(document2); - while (popover) { - hidePopover2(popover, focusPreviousElement, fireEvents); - popover = topMostAutoPopover2(document2); - } - } - function hideAllPopoversUntil2(endpoint, focusPreviousElement, fireEvents) { - const document2 = endpoint.ownerDocument || endpoint; - if (endpoint instanceof Document) { - return closeAllOpenPopovers2(document2, focusPreviousElement, fireEvents); - } - let lastToHide = null; - let foundEndpoint = false; - for (const popover of autoPopoverList2.get(document2) || []) { - if (popover === endpoint) { - foundEndpoint = true; - } else if (foundEndpoint) { - lastToHide = popover; - break; - } - } - if (!foundEndpoint) { - return closeAllOpenPopovers2(document2, focusPreviousElement, fireEvents); - } - while (lastToHide && getPopoverVisibilityState2(lastToHide) === "showing" && autoPopoverList2.get(document2)?.size) { - hidePopover2(lastToHide, focusPreviousElement, fireEvents); - } - } - var popoverPointerDownTargets2 = /* @__PURE__ */ new WeakMap(); - function lightDismissOpenPopovers2(event) { - if (!event.isTrusted) return; - const target = event.composedPath()[0]; - if (!target) return; - const document2 = target.ownerDocument; - const topMostPopover = topMostAutoPopover2(document2); - if (!topMostPopover) return; - const ancestor = topMostClickedPopover2(target); - if (ancestor && event.type === "pointerdown") { - popoverPointerDownTargets2.set(document2, ancestor); - } else if (event.type === "pointerup") { - const sameTarget = popoverPointerDownTargets2.get(document2) === ancestor; - popoverPointerDownTargets2.delete(document2); - if (sameTarget) { - hideAllPopoversUntil2(ancestor || document2, false, true); - } - } - } - var initialAriaExpandedValue2 = /* @__PURE__ */ new WeakMap(); - function setInvokerAriaExpanded2(el, force = false) { - if (!el) return; - if (!initialAriaExpandedValue2.has(el)) { - initialAriaExpandedValue2.set(el, el.getAttribute("aria-expanded")); - } - const popover = el.popoverTargetElement; - if (popover instanceof HTMLElement && popover.popover === "auto") { - el.setAttribute("aria-expanded", String(force)); - } else { - const initialValue = initialAriaExpandedValue2.get(el); - if (!initialValue) { - el.removeAttribute("aria-expanded"); - } else { - el.setAttribute("aria-expanded", initialValue); - } - } - } - var ShadowRoot23 = globalThis.ShadowRoot || function() { - }; - function isSupported2() { - return typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype === "object" && "popover" in HTMLElement.prototype; - } - function isPolyfilled() { - return Boolean( - document.body?.showPopover && !/native code/i.test(document.body.showPopover.toString()) - ); - } - function patchSelectorFn2(object, name, mapper) { - const original = object[name]; - Object.defineProperty(object, name, { - value(selector) { - return original.call(this, mapper(selector)); - } - }); - } - var nonEscapedPopoverSelector2 = /(^|[^\\]):popover-open\b/g; - function hasLayerSupport2() { - return typeof globalThis.CSSLayerBlockRule === "function"; - } - function getStyles2() { - const useLayer = hasLayerSupport2(); - return ` -${useLayer ? "@layer popover-polyfill {" : ""} - :where([popover]) { - position: fixed; - z-index: 2147483647; - inset: 0; - padding: 0.25em; - width: fit-content; - height: fit-content; - border-width: initial; - border-color: initial; - border-image: initial; - border-style: solid; - background-color: canvas; - color: canvastext; - overflow: auto; - margin: auto; - } - - :where([popover]:not(.\\:popover-open)) { - display: none; - } - - :where(dialog[popover].\\:popover-open) { - display: block; - } - - :where(dialog[popover][open]) { - display: revert; - } - - :where([anchor].\\:popover-open) { - inset: auto; - } - - :where([anchor]:popover-open) { - inset: auto; - } - - @supports not (background-color: canvas) { - :where([popover]) { - background-color: white; - color: black; - } - } - - @supports (width: -moz-fit-content) { - :where([popover]) { - width: -moz-fit-content; - height: -moz-fit-content; - } - } - - @supports not (inset: 0) { - :where([popover]) { - top: 0; - left: 0; - right: 0; - bottom: 0; - } - } -${useLayer ? "}" : ""} -`; - } - var popoverStyleSheet2 = null; - function injectStyles2(root) { - const styles = getStyles2(); - if (popoverStyleSheet2 === null) { - try { - popoverStyleSheet2 = new CSSStyleSheet(); - popoverStyleSheet2.replaceSync(styles); - } catch { - popoverStyleSheet2 = false; - } - } - if (popoverStyleSheet2 === false) { - const sheet = document.createElement("style"); - sheet.textContent = styles; - if (root instanceof Document) { - root.head.prepend(sheet); - } else { - root.prepend(sheet); - } - } else { - root.adoptedStyleSheets = [popoverStyleSheet2, ...root.adoptedStyleSheets]; - } - } - function apply2() { - if (typeof window === "undefined") return; - window.ToggleEvent = window.ToggleEvent || ToggleEvent2; - function rewriteSelector(selector) { - if (selector?.includes(":popover-open")) { - selector = selector.replace( - nonEscapedPopoverSelector2, - "$1.\\:popover-open" - ); - } - return selector; - } - patchSelectorFn2(Document.prototype, "querySelector", rewriteSelector); - patchSelectorFn2(Document.prototype, "querySelectorAll", rewriteSelector); - patchSelectorFn2(Element.prototype, "querySelector", rewriteSelector); - patchSelectorFn2(Element.prototype, "querySelectorAll", rewriteSelector); - patchSelectorFn2(Element.prototype, "matches", rewriteSelector); - patchSelectorFn2(Element.prototype, "closest", rewriteSelector); - patchSelectorFn2( - DocumentFragment.prototype, - "querySelectorAll", - rewriteSelector - ); - Object.defineProperties(HTMLElement.prototype, { - popover: { - enumerable: true, - configurable: true, - get() { - if (!this.hasAttribute("popover")) return null; - const value3 = (this.getAttribute("popover") || "").toLowerCase(); - if (value3 === "" || value3 == "auto") return "auto"; - return "manual"; - }, - set(value3) { - if (value3 === null) { - this.removeAttribute("popover"); - } else { - this.setAttribute("popover", value3); - } - } - }, - showPopover: { - enumerable: true, - configurable: true, - value() { - showPopover2(this); - } - }, - hidePopover: { - enumerable: true, - configurable: true, - value() { - hidePopover2(this, true, true); - } - }, - togglePopover: { - enumerable: true, - configurable: true, - value(force) { - if (visibilityState2.get(this) === "showing" && force === void 0 || force === false) { - hidePopover2(this, true, true); - } else if (force === void 0 || force === true) { - showPopover2(this); - } - } - } - }); - const originalAttachShadow = Element.prototype.attachShadow; - if (originalAttachShadow) { - Object.defineProperties(Element.prototype, { - attachShadow: { - enumerable: true, - configurable: true, - writable: true, - value(options) { - const shadowRoot = originalAttachShadow.call(this, options); - injectStyles2(shadowRoot); - return shadowRoot; - } - } - }); - } - const originalAttachInternals = HTMLElement.prototype.attachInternals; - if (originalAttachInternals) { - Object.defineProperties(HTMLElement.prototype, { - attachInternals: { - enumerable: true, - configurable: true, - writable: true, - value() { - const internals = originalAttachInternals.call(this); - if (internals.shadowRoot) { - injectStyles2(internals.shadowRoot); - } - return internals; - } - } - }); - } - const popoverTargetAssociatedElements = /* @__PURE__ */ new WeakMap(); - function applyPopoverInvokerElementMixin(ElementClass) { - Object.defineProperties(ElementClass.prototype, { - popoverTargetElement: { - enumerable: true, - configurable: true, - set(targetElement) { - if (targetElement === null) { - this.removeAttribute("popovertarget"); - popoverTargetAssociatedElements.delete(this); - } else if (!(targetElement instanceof Element)) { - throw new TypeError( - `popoverTargetElement must be an element or null` - ); - } else { - this.setAttribute("popovertarget", ""); - popoverTargetAssociatedElements.set(this, targetElement); - } - }, - get() { - if (this.localName !== "button" && this.localName !== "input") { - return null; - } - if (this.localName === "input" && this.type !== "reset" && this.type !== "image" && this.type !== "button") { - return null; - } - if (this.disabled) { - return null; - } - if (this.form && this.type === "submit") { - return null; - } - const targetElement = popoverTargetAssociatedElements.get(this); - if (targetElement && targetElement.isConnected) { - return targetElement; - } else if (targetElement && !targetElement.isConnected) { - popoverTargetAssociatedElements.delete(this); - return null; - } - const root = getRootNode2(this); - const idref = this.getAttribute("popovertarget"); - if ((root instanceof Document || root instanceof ShadowRoot23) && idref) { - return root.getElementById(idref) || null; - } - return null; - } - }, - popoverTargetAction: { - enumerable: true, - configurable: true, - get() { - const value3 = (this.getAttribute("popovertargetaction") || "").toLowerCase(); - if (value3 === "show" || value3 === "hide") return value3; - return "toggle"; - }, - set(value3) { - this.setAttribute("popovertargetaction", value3); - } - } - }); - } - applyPopoverInvokerElementMixin(HTMLButtonElement); - applyPopoverInvokerElementMixin(HTMLInputElement); - const handleInvokerActivation = (event) => { - const composedPath = event.composedPath(); - const target = composedPath[0]; - if (!(target instanceof Element) || target?.shadowRoot) { - return; - } - const root = getRootNode2(target); - if (!(root instanceof ShadowRoot23 || root instanceof Document)) { - return; - } - const invoker = composedPath.find( - (el) => el.matches?.("[popovertargetaction],[popovertarget]") - ); - if (invoker) { - popoverTargetAttributeActivationBehavior2(invoker); - event.preventDefault(); - return; - } - }; - const onKeydown = (event) => { - const key = event.key; - const target = event.target; - if (!event.defaultPrevented && target && (key === "Escape" || key === "Esc")) { - hideAllPopoversUntil2(target.ownerDocument, true, true); - } - }; - const addEventListeners = (root) => { - root.addEventListener("click", handleInvokerActivation); - root.addEventListener("keydown", onKeydown); - root.addEventListener("pointerdown", lightDismissOpenPopovers2); - root.addEventListener("pointerup", lightDismissOpenPopovers2); - }; - addEventListeners(document); - injectStyles2(document); - } - - // js/utils.js - function inject(callback) { - let styles = callback({ - css: (strings, ...values) => `@layer base { ${strings.raw[0] + values.join("")} }` - }); - if (document.adoptedStyleSheets === void 0) { - let styleElement = document.createElement("style"); - styleElement.textContent = styles; - document.head.appendChild(styleElement); - return; - } - let sheet = new CSSStyleSheet(); - sheet.replaceSync(styles); - document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; - } - function closest(el, condition) { - let current = el; - while (current) { - if (condition(current)) return current; - current = current.parentElement; - } - } - function walker(el, callback) { - let walker2 = document.createTreeWalker( - el, - NodeFilter.SHOW_ELEMENT, - callback ? { - acceptNode: (el2) => { - let skipped, rejected; - callback(el2, { - skip: () => skipped = true, - reject: () => rejected = true - }); - if (skipped) return NodeFilter.FILTER_SKIP; - if (rejected) return NodeFilter.FILTER_REJECT; - return NodeFilter.FILTER_ACCEPT; - } - } : {} - ); - return new Traverse(walker2); - } - var Traverse = class { - constructor(walker2) { - this.walker = walker2; - } - from(el) { - this.walker.currentNode = el; - return this; - } - first() { - return this.walker.firstChild(); - } - last() { - return this.walker.lastChild(); - } - next(el) { - this.walker.currentNode = el; - return this.walker.nextSibling(); - } - nextOrFirst(el) { - let found = this.next(el); - if (found) return found; - this.walker.currentNode = this.walker.root; - return this.first(); - } - prev(el) { - this.walker.currentNode = el; - return this.walker.previousSibling(); - } - prevOrLast(el) { - let found = this.prev(el); - if (found) return found; - this.walker.currentNode = this.walker.root; - return this.last(); - } - closest(el, condition) { - let walker2 = this.from(el).walker; - while (walker2.currentNode) { - if (condition(walker2.currentNode)) return walker2.currentNode; - walker2.parentNode(); - } - } - contains(el) { - return this.find((i) => i === el); - } - find(callback) { - return this.walk((el, bail) => { - callback(el) && bail(el); - }); - } - findOrFirst(callback) { - let found = this.find(callback); - if (!found) this.walker.currentNode = this.walker.root; - return this.first(); - } - each(callback) { - this.walk((el) => callback(el)); - } - some(callback) { - return !!this.find(callback); - } - every(callback) { - let every = true; - this.walk((el) => { - callback(el) || (every = false); - }); - return every; - } - map(callback) { - let els = []; - this.walk((el) => els.push(callback(el))); - return els; - } - filter(callback) { - let els = []; - this.walk((el) => callback(el) && els.push(el)); - return els; - } - walk(callback) { - let current; - let walker2 = this.walker; - let bailed; - while (walker2.nextNode()) { - current = walker2.currentNode; - callback(current, (bailValue) => bailed = bailValue); - if (bailed !== void 0) { - break; - } - } - return bailed; - } - }; - function element(name, type) { - customElements.define(`ui-${name}`, type); - } - function on(target, event, handler, options = {}) { - target.addEventListener(event, handler, options); - return { - off: () => target.removeEventListener(event, handler), - pause: (callback) => { - target.removeEventListener(event, handler), callback(); - target.addEventListener(event, handler); - } - }; - } - function isFocusable3(el) { - let selectors = [ - "a[href]", - "area[href]", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - "button:not([disabled])", - "iframe", - "object", - "embed", - "[tabindex]", - "[contenteditable]" - ]; - return selectors.some((selector) => el.matches(selector)) && el.tabIndex >= 0; - } - function throttle(func, limit) { - let inThrottle; - return function() { - let context = this, args = arguments; - if (!inThrottle) { - func.apply(context, args); - inThrottle = true; - setTimeout(() => inThrottle = false, limit); - } - }; - } - function timeout(callback, delay) { - let timerId; - let start; - let remaining = delay; - let active = false; - let timeout2 = { - pause: () => { - if (!active) return; - clearTimeout(timerId); - remaining -= Date.now() - start; - active = false; - }, - resume: () => { - if (active) return; - start = Date.now(); - timerId = setTimeout(callback, remaining); - active = true; - }, - cancel: () => { - clearTimeout(timerId); - active = false; - remaining = delay; - } - }; - timeout2.resume(); - return timeout2; - } - var using = "pointer"; - document.addEventListener("keydown", () => using = "keyboard", { capture: true }); - document.addEventListener("pointerdown", (e) => { - using = e.pointerType === "mouse" ? "mouse" : "touch"; - }, { capture: true }); - document.addEventListener("pointermove", (e) => { - using = e.pointerType === "mouse" ? "mouse" : "touch"; - }, { capture: true }); - function isUsingKeyboard() { - return using === "keyboard"; - } - function isUsingTouch() { - return using === "touch"; - } - function search(el, callback) { - let runningQuery = ""; - let clearRunningQuery = debounce(() => { - runningQuery = ""; - }, 300); - el.addEventListener("keydown", (e) => { - if (e.key.length === 1 && /[a-zA-Z]/.test(e.key)) { - runningQuery += e.key; - callback(runningQuery); - e.stopPropagation(); - } - clearRunningQuery(); - }); - } - function dispenseId(el, prefix) { - return "lofi-" + (prefix ? prefix + "-" : "") + Math.random().toString(16).slice(2); - } - function assignId(el, prefix) { - let id = el.hasAttribute("id") ? el.getAttribute("id") : dispenseId(el, prefix); - setAttribute2(el, "id", id); - if (!el._x_bindings) el._x_bindings = {}; - if (!el._x_bindings.id) el._x_bindings.id = id; - return id; - } - function detangle() { - let blocked = false; - return (callback) => (...args) => { - if (blocked) return; - blocked = true; - callback(...args); - blocked = false; - }; - } - function interest(trigger, panel, { gain, lose, focusable, useSafeArea }) { - let engaged = false; - let focusInHander = (e) => { - if (!isUsingKeyboard()) return; - if (trigger.contains(e.target) || panel.contains(e.target)) { - engaged = true; - gain(); - } else { - engaged = false; - lose(); - } - }; - focusable && document.addEventListener("focusin", focusInHander); - let removeFocusInHandler = () => { - document.removeEventListener("focusin", focusInHander); - }; - let removeSafeArea = () => { - }; - let removePointerMoveHandler = () => { - }; - let disinterest = () => { - engaged = false; - lose(); - removeSafeArea(); - removePointerMoveHandler(); - }; - let clear = () => { - engaged = false; - removeSafeArea(); - removePointerMoveHandler(); - }; - let pointerEnterHandler = (e) => { - if (isUsingTouch()) return; - if (engaged) return; - engaged = true; - gain(); - setTimeout(() => { - let { safeArea, redraw: redrawSafeArea, remove: remove2 } = useSafeArea ? createSafeArea(trigger, panel, e.clientX, e.clientY) : nullSafeArea(); - removeSafeArea = remove2; - let pointerStoppedOverSafeAreaTimeout; - let pointerMoveHandler = throttle((e2) => { - let panelRect = panel.getBoundingClientRect(); - let triggerRect = trigger.getBoundingClientRect(); - let mouseState; - if (safeArea.contains(e2.target) && mouseIsExclusivelyInsideSafeArea(triggerRect, panelRect, e2.clientX, e2.clientY)) mouseState = "safeArea"; - else if (panel.contains(e2.target)) mouseState = "panel"; - else if (trigger.contains(e2.target)) mouseState = "trigger"; - else mouseState = "outside"; - if (pointerStoppedOverSafeAreaTimeout) { - clearTimeout(pointerStoppedOverSafeAreaTimeout); - } - switch (mouseState) { - case "outside": - disinterest(); - break; - case "trigger": - redrawSafeArea(e2.clientX, e2.clientY); - break; - case "panel": - removeSafeArea(); - break; - case "safeArea": - redrawSafeArea(e2.clientX, e2.clientY); - pointerStoppedOverSafeAreaTimeout = setTimeout(() => { - disinterest(); - }, 300); - break; - default: - break; - } - }, 100); - document.addEventListener("pointermove", pointerMoveHandler); - removePointerMoveHandler = () => document.removeEventListener("pointermove", pointerMoveHandler); - }); - }; - trigger.addEventListener("pointerenter", pointerEnterHandler); - let removePointerEnterHandler = () => { - trigger.removeEventListener("pointerenter", pointerEnterHandler); - }; - let remove = () => { - clear(); - removePointerEnterHandler(); - removeFocusInHandler(); - }; - return { clear, remove }; - } - function createSafeArea(trigger, panel, x, y) { - let safeArea = document.createElement("div"); - let panelRect = panel.getBoundingClientRect(); - let triggerRect = trigger.getBoundingClientRect(); - safeArea.style.position = "fixed"; - setAttribute2(safeArea, "data-safe-area", ""); - let draw = (x2, y2) => { - if (panelRect.top === 0 && panelRect.bottom === 0) return; - let direction; - if (panelRect.left < triggerRect.left) direction = "left"; - if (panelRect.right > triggerRect.right) direction = "right"; - if (panelRect.top < triggerRect.top && panelRect.bottom < y2) direction = "up"; - if (panelRect.bottom > triggerRect.bottom && panelRect.top > y2) direction = "down"; - if (direction === void 0) direction = "right"; - let left, right, width, top, bottom, height, offset3, shape; - let padding = 10; - switch (direction) { - case "left": - left = panelRect.right; - right = Math.max(panelRect.right, x2) + 5; - width = right - left; - top = Math.min(triggerRect.top, panelRect.top) - padding; - bottom = Math.max(triggerRect.bottom, panelRect.bottom) + padding; - height = bottom - top; - offset3 = y2 - top; - shape = `polygon(0% 0%, 100% ${offset3}px, 0% 100%)`; - break; - case "right": - left = Math.min(panelRect.left, x2) - 5; - right = panelRect.left; - width = right - left; - top = Math.min(triggerRect.top, panelRect.top) - padding; - bottom = Math.max(triggerRect.bottom, panelRect.bottom) + padding; - height = bottom - top; - offset3 = y2 - top; - shape = `polygon(0% ${offset3}px, 100% 0%, 100% 100%)`; - break; - case "up": - left = Math.min(x2, panelRect.left) - padding; - right = Math.max(x2, panelRect.right) + padding; - width = right - left; - top = panelRect.bottom; - bottom = Math.max(panelRect.bottom, y2) + 5; - height = bottom - top; - offset3 = x2 - left; - shape = `polygon(0% 0%, 100% 0%, ${offset3}px 100%)`; - break; - case "down": - left = Math.min(x2, panelRect.left) - padding; - right = Math.max(x2, panelRect.right) + padding; - width = right - left; - top = Math.min(panelRect.top, y2) - 5; - bottom = panelRect.top; - height = bottom - top; - offset3 = x2 - left; - shape = `polygon(${offset3}px 0%, 100% 100%, 0% 100%)`; - break; - } - safeArea.style.left = `${left}px`; - safeArea.style.top = `${top}px`; - safeArea.style.width = `${width}px`; - safeArea.style.height = `${height}px`; - safeArea.style.clipPath = shape; - }; - return { - safeArea, - redraw: (x2, y2) => { - if (!safeArea.isConnected) trigger.appendChild(safeArea); - draw(x2, y2); - }, - remove: () => { - safeArea.remove(); - } - }; - } - function mouseIsExclusivelyInsideSafeArea(triggerRect, panelRect, x, y) { - return !mouseIsOverTrigger(triggerRect, x, y) && !mouseIsOverPanel(panelRect, x, y); - } - function mouseIsOverTrigger(triggerRect, x, y) { - if (triggerRect.left <= x && x <= triggerRect.right && (triggerRect.top <= y && y <= triggerRect.bottom)) return true; - return false; - } - function mouseIsOverPanel(panelRect, x, y) { - if (panelRect.left <= x && x <= panelRect.right && (panelRect.top <= y && y <= panelRect.bottom)) return true; - return false; - } - function setAttribute2(el, name, value3) { - if (el._durableAttributeObserver === void 0) { - el._durableAttributeObserver = attributeObserver(el, [name]); - } - if (!el._durableAttributeObserver.hasAttribute(name)) { - el._durableAttributeObserver.addAttribute(name); - } - el._durableAttributeObserver.pause(() => { - el.setAttribute(name, value3); - }); - } - function removeAndReleaseAttribute(el, name) { - removeAttribute(el, name); - releaseAttribute(el, name); - } - function removeAttribute(el, name) { - if (el._durableAttributeObserver === void 0) { - el._durableAttributeObserver = attributeObserver(el, [name]); - } - if (!el._durableAttributeObserver.hasAttribute(name)) { - el._durableAttributeObserver.addAttribute(name); - } - el._durableAttributeObserver.pause(() => { - el.removeAttribute(name); - }); - } - function releaseAttribute(el, name) { - if (!el?._durableAttributeObserver?.hasAttribute(name)) return; - el._durableAttributeObserver.releaseAttribute(name); - } - function attributeObserver(el, initialAttributes) { - let processMutations = (mutations) => { - mutations.forEach((mutation) => { - if (mutation.oldValue === null) { - el._durableAttributeObserver.pause(() => removeAttribute(el, mutation.attributeName)); - } else { - el._durableAttributeObserver.pause(() => setAttribute2(el, mutation.attributeName, mutation.oldValue)); - } - }); - }; - let observer = new MutationObserver((mutations) => processMutations(mutations)); - observer.observe(el, { attributeFilter: initialAttributes, attributeOldValue: true }); - return { - attributes: initialAttributes, - hasAttribute(name) { - return this.attributes.includes(name); - }, - addAttribute(name) { - this.attributes.includes(name) || this.attributes.push(name); - observer.observe(el, { attributeFilter: this.attributes, attributeOldValue: true }); - }, - releaseAttribute(name) { - if (!this.hasAttribute(name)) return; - observer.observe(el, { attributeFilter: this.attributes, attributeOldValue: true }); - }, - pause(callback) { - processMutations(observer.takeRecords()); - observer.disconnect(); - callback(); - observer.observe(el, { attributeFilter: this.attributes, attributeOldValue: true }); - } - }; - } - function nullSafeArea() { - return { - safeArea: { contains: () => false }, - redraw: () => { - }, - remove: () => { - } - }; - } - function debounce(callback, delay) { - let timeout2; - return (...args) => { - clearTimeout(timeout2); - timeout2 = setTimeout(() => { - callback(...args); - }, delay); - }; - } - var lockCount = 0; - var pointerEventsLocked = false; - inject(({ css }) => css`[data-flux-allow-scroll] { pointer-events: auto; }`); - function lockScroll(el = null, allowScroll = false, except = []) { - if (allowScroll) return { lock: () => { - }, unlock: () => { - } }; - let applyDocumentLockStyles = (disablePointerEvents = false) => { - undoLockStyles(document.documentElement); - let lockStyles = { - overflow: "hidden", - ...disablePointerEvents ? { pointerEvents: "none" } : {} - }; - if (window.CSS && CSS.supports && CSS.supports("scrollbar-gutter: stable")) { - if (document.documentElement.scrollHeight > document.documentElement.clientHeight) { - lockStyles.scrollbarGutter = "stable"; - } - } else { - lockStyles.paddingRight = `calc(${window.innerWidth - document.documentElement.clientWidth}px + ${window.getComputedStyle(document.documentElement).paddingRight})`; - } - setLockStyles(document.documentElement, lockStyles); - if (disablePointerEvents) { - setAttribute2(el, "data-flux-allow-scroll", ""); - except.forEach((el2) => { - setAttribute2(el2, "data-flux-allow-scroll", ""); - }); - pointerEventsLocked = true; - } - }; - let removeDocumentLockStyles = (enablePointerEvents = false) => { - undoLockStyles(document.documentElement); - if (enablePointerEvents) { - removeAndReleaseAttribute(el, "data-flux-allow-scroll"); - except.forEach((el2) => { - removeAttribute(el2, "data-flux-allow-scroll"); - }); - pointerEventsLocked = false; - } - }; - return { - lock() { - lockCount++; - if (lockCount > 1 && el !== null && pointerEventsLocked) return; - applyDocumentLockStyles(el !== null && !pointerEventsLocked); - }, - unlock() { - lockCount = Math.max(0, lockCount - 1); - if (lockCount > 0 && el !== null && !pointerEventsLocked) return; - removeDocumentLockStyles(el !== null && pointerEventsLocked); - if (lockCount > 0) { - applyDocumentLockStyles(false); - } - } - }; - } - function setLockStyles(element2, styles) { - let unlockedStyles = JSON.parse(element2.getAttribute(`data-flux-scroll-unlock`) || "{}"); - Object.entries(styles).forEach(([style, value3]) => { - if (unlockedStyles[style] === void 0) { - unlockedStyles[style] = element2.style[style]; - element2.style[style] = value3; - } - }); - element2.setAttribute(`data-flux-scroll-unlock`, JSON.stringify(unlockedStyles)); - } - function undoLockStyles(element2) { - let unlockedStyles = JSON.parse(element2.getAttribute(`data-flux-scroll-unlock`) || "{}"); - Object.entries(unlockedStyles).forEach(([style, value3]) => { - element2.style[style] = value3; - }); - element2.removeAttribute(`data-flux-scroll-unlock`); - } - function setStyle(element2, style, value3) { - let currentValue = element2.style[style]; - element2.style[style] = value3; - return () => { - element2.style[style] = currentValue; - }; - } - function initFauxButton(el, isDisabled, action) { - let ifKey = (key, callback) => (e) => { - if (e.key === key && !isDisabled()) { - callback(); - e.preventDefault(); - e.stopPropagation(); - } - }; - setAttribute2(el, "role", "button"); - let syncDisabledAttributes = () => { - if (el.hasAttribute("disabled")) { - setAttribute2(el, "aria-disabled", "true"); - setAttribute2(el, "tabindex", "-1"); - } else { - removeAttribute(el, "aria-disabled"); - setAttribute2(el, "tabindex", "0"); - } - }; - let observer = new MutationObserver(() => syncDisabledAttributes()); - observer.observe(el, { attributes: true, attributeFilter: ["disabled"] }); - syncDisabledAttributes(); - on(el, "click", () => action()); - on(el, "keydown", ifKey("Enter", () => action())); - on(el, "keydown", ifKey(" ", () => { - })); - on(el, "keyup", ifKey(" ", () => action())); - } - function responsiveAttributeValue(el, name, fallback = null) { - let getValue = () => { - let value3 = el.getAttribute(name); - let breakpoints = { - sm: 640, - md: 768, - lg: 1024, - xl: 1280, - "2xl": 1536 - }; - for (let [breakpoint, minWidth] of Object.entries(breakpoints).reverse()) { - let responsiveValue = el.getAttribute(`${breakpoint}:${name}`); - if (responsiveValue && window.innerWidth >= minWidth) { - return responsiveValue; - } - } - return value3 || fallback; - }; - let currentValue = getValue(); - let callbacks = []; - new ResizeObserver(() => { - let newValue = getValue(); - let memo = JSON.stringify(currentValue); - if (JSON.stringify(newValue) !== memo) { - currentValue = newValue; - callbacks.forEach((callback) => callback(newValue)); - } - }).observe(window.document.documentElement); - return [currentValue, (callback) => callbacks.push(callback)]; - } - function getLocale() { - return navigator?.language || document.documentElement.lang || "en-US"; - } - function hydrateTemplate(template, slotsAndAttributes = { slots: {}, attrs: {} }) { - let { slots = {}, attrs = {} } = slotsAndAttributes; - let clone = template.content.cloneNode(true).firstElementChild; - Object.entries(slots).forEach(([key, value3]) => { - let slotNodes = key === "default" ? clone.querySelectorAll("slot:not([name])") : clone.querySelectorAll(`slot[name="${key}"]`); - slotNodes.forEach((i) => i.replaceWith( - typeof value3 === "string" ? document.createTextNode(value3) : value3 - )); - }); - clone.querySelectorAll("slot").forEach((slot) => slot.remove()); - Object.entries(attrs).forEach(([key, value3]) => { - clone.setAttribute(key, value3); - }); - clone.setAttribute("data-appended", ""); - return clone; - } - function isRTL() { - return document.documentElement.dir === "rtl"; - } - function isSafari() { - return /^((?!chrome|android).)*safari/i.test(navigator.userAgent) && !navigator.userAgent.includes("CriOS") && !navigator.userAgent.includes("FxiOS"); - } - function isIOS() { - return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; - } - var Observable = class { - constructor() { - this.subscribers = []; - } - subscribe(reason, callback) { - this.subscribers.push({ reason, callback }); - } - notify(reason, data) { - this.subscribers.forEach(({ reason: subReason, callback }) => { - if (reason === subReason) { - callback(data); - } - }); - } - }; - - // js/element.js - var UIElement = class extends HTMLElement { - wasDisconnected = false; - constructor() { - super(); - this.boot?.(); - } - connectedCallback() { - if (this.wasDisconnected) { - this.wasDisconnected = false; - return; - } - queueMicrotask(() => { - this.mount?.(); - }); - } - disconnectedCallback() { - this.wasDisconnected = true; - queueMicrotask(() => { - if (this.wasDisconnected) { - this.unmount?.(); - } - this.wasDisconnected = false; - }); - } - mixin(func, options = {}) { - return new func(this, options); - } - // @todo: this is redundant now... - appendMixin(func, options = {}) { - return new func(this, options); - } - use(func) { - let found; - this.mixins.forEach((mixin) => { - if (mixin instanceof func) found = mixin; - }); - return found; - } - uses(func) { - let found; - this.mixins.forEach((mixin) => { - if (mixin instanceof func) found = true; - }); - return !!found; - } - on(event, handler) { - return on(this, event, handler); - } - root(name, attributes = {}) { - if (name === void 0) return this.__root; - let el = document.createElement(name); - for (let name2 in attributes) { - setAttribute(el, name2, attributes[name2]); - } - let shadow = this.attachShadow({ mode: "open" }); - el.appendChild(document.createElement("slot")); - shadow.appendChild(el); - this.__root = el; - return this.__root; - } - }; - var UIControl = class extends UIElement { - // - }; - - // js/mixins/mixin.js - var Mixin = class { - constructor(el, options = {}) { - this.el = el; - this.grouped = options.grouped === void 0 ? true : false; - this.el.mixins = this.el.mixins ? this.el.mixins : /* @__PURE__ */ new Map(); - this.el.mixins.set(this.constructor.name, this); - this.el[this.constructor.name] = true; - if (!this.el.use) this.el.use = UIElement.prototype.use.bind(this.el); - this.opts = options; - this.boot?.({ - options: (defaults) => { - let options2 = defaults; - Object.entries(this.opts).forEach(([key, value3]) => { - if (value3 !== void 0) { - options2[key] = value3; - } - }); - this.opts = options2; - } - }); - queueMicrotask(() => { - this.mount?.(); - }); - } - options() { - return this.opts; - } - hasGroup() { - return !!this.group(); - } - group() { - if (this.grouped === false) return; - return closest(this.el, (i) => i[this.groupedByType.name])?.use(this.groupedByType); - } - on(event, handler) { - return on(this.el, event, handler); - } - }; - var MixinGroup = class extends Mixin { - constructor(el, options = {}) { - super(el, options); - } - walker() { - return walker(this.el, (el, { skip, reject }) => { - if (el[this.constructor.name] && el !== this.el) return reject(); - if (!el[this.groupOfType.name]) return skip(); - if (!el.mixins.get(this.groupOfType.name).grouped) return skip(); - }); - } - }; - - // js/mixins/controllable.js - var Controllable = class extends Mixin { - boot({ options }) { - options({ - bubbles: false - }); - this.initialState = this.el.value; - this.getterFunc = () => { - }; - this.setterFunc = (value3) => this.initialState = value3; - Object.defineProperty(this.el, "value", { - get: () => { - return this.getterFunc(); - }, - set: (value3) => { - this.setterFunc(value3); - } - }); - } - initial(callback) { - callback(this.initialState); - } - getter(func) { - this.getterFunc = func; - } - setter(func) { - this.setterFunc = func; - } - dispatch() { - this.el.dispatchEvent(new Event("input", { - bubbles: this.options().bubbles, - cancelable: true - })); - this.el.dispatchEvent(new Event("change", { - bubbles: this.options().bubbles, - cancelable: true - })); - } - }; - - // js/mixins/dialogable.js - var lastMouseDownEvent = null; - document.addEventListener("mousedown", (event) => lastMouseDownEvent = event); - var Dialogable = class extends Mixin { - boot({ options }) { - options({ - clickOutside: true, - triggers: [] - }); - this.onChanges = []; - this.state = false; - this.stopDialogFromFocusingTheFirstElement(); - let triggers = this.options().triggers; - let observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.attributeName !== "open") return; - this.el.hasAttribute("open") ? this.state = true : this.state = false; - }); - this.onChanges.forEach((i) => i()); - }); - observer.observe(this.el, { attributeFilter: ["open"] }); - if (this.options().clickOutside) { - this.el.addEventListener("click", (e) => { - if (e.target !== this.el) { - lastMouseDownEvent = null; - return; - } - if (lastMouseDownEvent && clickHappenedOutside(this.el, lastMouseDownEvent) && clickHappenedOutside(this.el, e)) { - this.cancel(); - e.preventDefault(); - e.stopPropagation(); - } - lastMouseDownEvent = null; - }); - } - if (this.el.hasAttribute("open")) { - this.state = true; - this.hide(); - this.show(); - } - } - onChange(callback) { - this.onChanges.push(callback); - } - show() { - if (!this.el.isConnected) return; - this.el.showModal(); - } - hide() { - this.el.close(); - } - toggle() { - this.state ? this.hide() : this.show(); - } - cancel() { - let event = new Event("cancel", { bubbles: false, cancelable: true }); - this.el.dispatchEvent(event); - if (!event.defaultPrevented) { - this.hide(); - } - } - getState() { - return this.state; - } - setState(value3) { - value3 ? this.show() : this.hide(); - } - // By default, browsers focus the first focusable element inside a dialog when it is opened. This is bad for screen readers because - // the focus could potentially be at the end of the dialog skipping all of the content. This also causes issues for iOS devices - // as when inputs are focused and the keyboard is shown, hiding half of the dialog content... - stopDialogFromFocusingTheFirstElement() { - let placeholder = document.createElement("div"); - placeholder.setAttribute("data-flux-focus-placeholder", ""); - placeholder.setAttribute("data-appended", ""); - placeholder.setAttribute("tabindex", "0"); - this.el.prepend(placeholder); - this.onChange(() => { - setAttribute2(placeholder, "style", this.state ? "display: none" : "display: block"); - if (this.state && isSafari() && !this.el.hasAttribute("autofocus") && this.el.querySelectorAll("[autofocus]").length === 0) { - setTimeout(() => { - this.el.setAttribute("tabindex", "-1"); - this.el.focus(); - this.el.blur(); - }); - } - }); - } - }; - function clickHappenedOutside(el, event) { - let rect = el.getBoundingClientRect(); - let x = event.clientX; - let y = event.clientY; - let isInside = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; - return !isInside; - } - - // js/mixins/closeable.js - var Closeable = class extends Mixin { - boot() { - this.onCloses = []; - } - onClose(callback) { - this.onCloses.push(callback); - } - close() { - this.onCloses.forEach((callback) => callback()); - } - }; - - // js/modal.js - var UIModal = class extends UIElement { - boot() { - this.querySelectorAll("[data-appended]").forEach((el) => el.remove()); - this._controllable = new Controllable(this, { disabled: this.hasAttribute("disabled") }); - let button = this.button(); - let dialog = this.dialog(); - if (!dialog) return; - dialog._dialogable = new Dialogable(dialog, { - clickOutside: !this.hasAttribute("disable-click-outside") - }); - dialog._closeable = new Closeable(dialog); - dialog._closeable.onClose(() => dialog._dialogable.hide()); - this._controllable.initial((initial) => initial && dialog._dialogable.show()); - this._controllable.getter(() => dialog._dialogable.getState()); - let detangled = detangle(); - this._controllable.setter(detangled((value3) => { - dialog._dialogable.setState(value3); - })); - dialog._dialogable.onChange(detangled(() => { - this._controllable.dispatch(); - })); - let refresh = () => { - if (dialog._dialogable.getState()) { - setAttribute2(this, "data-open", ""); - button?.setAttribute("data-open", ""); - setAttribute2(dialog, "data-open", ""); - } else { - removeAttribute(this, "data-open"); - button?.removeAttribute("data-open"); - removeAttribute(dialog, "data-open"); - } - }; - dialog._dialogable.onChange(() => refresh()); - refresh(); - let { lock, unlock } = lockScroll(); - dialog._dialogable.onChange(() => { - dialog._dialogable.getState() ? lock() : unlock(); - }); - button && on(button, "click", (e) => { - dialog._dialogable.show(); - }); - } - unmount() { - if (this.dialog()?._dialogable?.getState()) { - let { unlock } = lockScroll(); - unlock(); - } - } - button() { - let button = this.querySelector("button,ui-button"); - let dialog = this.dialog(); - if (dialog?.contains(button)) return; - return button; - } - dialog() { - return this.querySelector("dialog"); - } - showModal() { - let dialog = this.dialog(); - if (!dialog) return; - dialog.showModal(); - } - }; - inject(({ css }) => css`dialog, ::backdrop { margin: auto; }`); - element("modal", UIModal); - - // js/mixins/activatable.js - var ActivatableGroup = class extends MixinGroup { - groupOfType = Activatable; - boot({ options }) { - options({ - wrap: false, - filter: false - }); - this.onChanges = []; - } - onChange(callback) { - this.onChanges.push(callback); - } - activated(activeEl) { - this.onChanges.forEach((i) => i()); - } - activateFirst() { - this.filterAwareWalker().first()?.use(Activatable).activate(); - } - activateBySearch(query) { - let found = this.filterAwareWalker().find((i) => i.textContent.toLowerCase().trim().startsWith(query.toLowerCase())); - found?.use(Activatable).activate(); - } - activateSelectedOrFirst(selectedEl) { - let isHidden = (el) => el.matches("ui-option, ui-option-create") ? getComputedStyle(el).display === "none" : false; - if (!selectedEl || isHidden(selectedEl)) { - this.filterAwareWalker().first()?.use(Activatable).activate(); - return; - } - selectedEl?.use(Activatable).activate(); - } - activateActiveOrFirst() { - let active = this.getActive(); - if (!active) { - this.filterAwareWalker().first()?.use(Activatable).activate(); - return; - } - active?.use(Activatable).activate(); - } - activateActiveOrLast() { - let active = this.getActive(); - if (!active) { - this.filterAwareWalker().last()?.use(Activatable).activate(); - return; - } - active?.use(Activatable).activate(); - } - activatePrev() { - let active = this.getActive(); - if (!active) { - this.filterAwareWalker().last()?.use(Activatable).activate(); - return; - } - let found; - if (this.options.wrap) { - found = this.filterAwareWalker().prevOrLast(active); - } else { - found = this.filterAwareWalker().prev(active); - } - found?.use(Activatable).activate(); - } - activateNext() { - let active = this.getActive(); - if (!active) { - this.filterAwareWalker().first()?.use(Activatable).activate(); - return; - } - let found; - if (this.options.wrap) { - found = this.filterAwareWalker().nextOrFirst(active); - } else { - found = this.filterAwareWalker().next(active); - } - found?.use(Activatable).activate(); - } - getActive() { - return this.walker().find((i) => i.use(Activatable).isActive()); - } - clearActive() { - this.getActive()?.use(Activatable).deactivate(); - } - filterAwareWalker() { - let isHidden = (el) => el.matches("ui-option, ui-option-create") ? getComputedStyle(el).display === "none" : false; - return walker(this.el, (el, { skip, reject }) => { - if (el[this.constructor.name] && el !== this.el) return reject(); - if (!el[this.groupOfType.name]) return skip(); - if (el.hasAttribute("disabled")) return reject(); - if (isHidden(el)) return reject(); - }); - } - }; - var Activatable = class _Activatable extends Mixin { - groupedByType = ActivatableGroup; - mount() { - this.el.addEventListener("mouseenter", () => { - this.activate(); - }); - this.el.addEventListener("mouseleave", () => { - this.deactivate(); - }); - } - activate(force = false) { - if (this.group()) { - this.group().walker().each((item) => item.use(_Activatable).deactivate(false)); - } - if (this.el.hasAttribute("disabled") && !force) return; - setAttribute2(this.el, "data-active", ""); - if (isUsingKeyboard()) { - this.el.scrollIntoView({ block: "nearest" }); - } - this.group() && this.group().activated(this.el); - } - deactivate(notify = true) { - removeAttribute(this.el, "data-active"); - notify && this.group() && this.group().activated(this.el); - } - isActive() { - return this.el.hasAttribute("data-active"); - } - }; - - // js/mixins/filterable.js - var FilterableGroup = class extends MixinGroup { - groupOfType = Filterable; - boot({ options }) { - options({}); - this.onChanges = []; - this.lastSearch = ""; - } - onChange(callback) { - this.onChanges.push(callback); - } - filter(search2) { - if (search2 === "") { - this.walker().each((i) => { - i.use(Filterable).unfilter(); - }); - } else { - this.walker().each((i) => { - if (this.matches(i, search2)) { - i.use(Filterable).unfilter(); - } else { - i.use(Filterable).filter(); - } - }); - } - if (this.lastSearch !== search2) { - this.onChanges.forEach((i) => i()); - } - this.lastSearch = search2; - } - matches(el, search2) { - return this.normalize(el.textContent).includes(this.normalize(search2)); - } - // This function normalizes the value to remove diacritics (accents) and convert to lowercase - // to ensure that the search is case-insensitive and diacritic-insensitive... - normalize(value3) { - return value3.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase().trim(); - } - hasResults() { - return this.walker().some((i) => !i.use(Filterable).isFiltered()); - } - }; - var Filterable = class extends Mixin { - groupedByType = FilterableGroup; - boot({ options }) { - options({ mirror: null, keep: false }); - this.onChanges = []; - } - filter() { - if (this.options().keep) return; - setAttribute2(this.el, "data-hidden", ""); - if (this.options().mirror) setAttribute2(this.options().mirror, "data-hidden", ""); - } - unfilter() { - if (this.options().keep) return; - removeAttribute(this.el, "data-hidden"); - if (this.options().mirror) removeAttribute(this.options().mirror, "data-hidden", ""); - } - isFiltered() { - return this.el.hasAttribute("data-hidden"); - } - }; - - // js/mixins/popoverable.js - var currentlyOpenPopoversByScope = /* @__PURE__ */ new Map(); - var Popoverable = class extends Mixin { - boot({ options }) { - options({ triggers: [], scope: null }); - let scope = this.options().scope || "global"; - setAttribute2(this.el, "popover", "manual"); - this.triggers = this.options().triggers; - this.onChanges = []; - this.state = false; - on(this.el, "beforetoggle", (e) => { - let oldState = this.state; - this.state = e.newState === "open"; - if (this.state) { - closeOtherOpenPopovers(this.el, scope); - let controller = new AbortController(); - let activeElement = document.activeElement; - let triggers = [...this.triggers, activeElement]; - setTimeout(() => { - closeOnClickOutside(this.el, triggers, controller); - closeOnFocusAway(this.el, triggers, controller); - closeOnEscape(this.el, triggers, controller); - }); - this.el.addEventListener("beforetoggle", (e2) => { - if (e2.newState === "closed") { - controller.abort(); - activeElement?.focus(); - } - }, { signal: controller.signal }); - } - if (oldState !== this.state) { - this.onChanges.forEach((i) => i(this.state, oldState)); - } - }); - on(this.el, "toggle", (e) => { - if (e.newState === "open") { - if (!currentlyOpenPopoversByScope.has(scope)) { - currentlyOpenPopoversByScope.set(scope, /* @__PURE__ */ new Set()); - } - currentlyOpenPopoversByScope.get(scope).add(this.el); - } else if (e.newState === "closed") { - if (!currentlyOpenPopoversByScope.has(scope)) return; - currentlyOpenPopoversByScope.get(scope).delete(this.el); - if (currentlyOpenPopoversByScope.get(scope).size === 0) { - currentlyOpenPopoversByScope.delete(scope); - } - } - }); - } - onChange(callback) { - this.onChanges.push(callback); - } - setState(value3) { - value3 ? this.show() : this.hide(); - } - getState() { - return this.state; - } - toggle() { - this.el.isConnected && this.el.togglePopover(); - } - show() { - this.el.isConnected && this.el.showPopover(); - } - hide() { - this.el.isConnected && this.el.hidePopover(); - } - }; - function closeOtherOpenPopovers(el, scope) { - if (!currentlyOpenPopoversByScope.has(scope)) return; - currentlyOpenPopoversByScope.get(scope).forEach((popoverEl) => { - if (el.contains(popoverEl) || popoverEl.contains(el)) return; - popoverEl.hidePopover(); - }); - } - function closeOnClickOutside(el, except, controller) { - document.addEventListener("click", (e) => { - if (el.contains(e.target) || except.includes(e.target)) return; - el.hidePopover(); - }, { signal: controller.signal }); - } - function closeOnFocusAway(el, except, controller) { - document.addEventListener("focusin", (e) => { - if (el.contains(e.target) || except.includes(e.target)) return; - controller.abort(); - el.hidePopover(); - }, { - // Without "capture: true", when you focus away from the popover onto an element that triggers a popover - // on focus (a tooltip), it will focus back this popover's trigger instead of keeping focus on the tooltip button. - // It does this because only one popover can be open at a time, so focusing the tooltip, opens a popover, closing this one, - // which will trigger the "focus back" behavior. - capture: true, - signal: controller.signal - }); - } - function closeOnEscape(el, except, controller) { - document.addEventListener("keydown", (e) => { - if (e.key !== "Escape") return; - el.hidePopover(); - }, { signal: controller.signal }); - } - - // js/mixins/disableable.js - var Disableable = class extends Mixin { - boot({ options }) { - options({ - disableWithParent: true - }); - this.onChanges = []; - Object.defineProperty(this.el, "disabled", { - get: () => { - return this.el.hasAttribute("disabled"); - }, - set: (value3) => { - if (value3) { - this.el.setAttribute("disabled", ""); - } else { - this.el.removeAttribute("disabled"); - } - } - }); - if (this.el.hasAttribute("disabled")) { - this.el.disabled = true; - } else if (this.options().disableWithParent && this.el.parentElement?.closest("[disabled]")) { - this.el.disabled = true; - } - let observer = new MutationObserver((mutations) => { - this.onChanges.forEach((i) => i(this.el.disabled)); - }); - observer.observe(this.el, { attributeFilter: ["disabled"] }); - } - onChange(callback) { - this.onChanges.push(callback); - } - onInitAndChange(callback) { - callback(this.el.disabled); - this.onChanges.push(callback); - } - enabled(callback) { - return (...args) => { - if (this.el.disabled) return; - return callback(...args); - }; - } - disabled(callback) { - return (...args) => { - if (!this.el.disabled) return; - return callback(...args); - }; - } - isDisabled() { - return this.el.disabled; - } - }; - - // node_modules/@floating-ui/utils/dist/floating-ui.utils.mjs - var min = Math.min; - var max = Math.max; - var round = Math.round; - var floor = Math.floor; - var createCoords = (v) => ({ - x: v, - y: v - }); - var oppositeSideMap = { - left: "right", - right: "left", - bottom: "top", - top: "bottom" - }; - var oppositeAlignmentMap = { - start: "end", - end: "start" - }; - function clamp(start, value3, end) { - return max(start, min(value3, end)); - } - function evaluate(value3, param) { - return typeof value3 === "function" ? value3(param) : value3; - } - function getSide(placement) { - return placement.split("-")[0]; - } - function getAlignment(placement) { - return placement.split("-")[1]; - } - function getOppositeAxis(axis) { - return axis === "x" ? "y" : "x"; - } - function getAxisLength(axis) { - return axis === "y" ? "height" : "width"; - } - function getSideAxis(placement) { - return ["top", "bottom"].includes(getSide(placement)) ? "y" : "x"; - } - function getAlignmentAxis(placement) { - return getOppositeAxis(getSideAxis(placement)); - } - function getAlignmentSides(placement, rects, rtl) { - if (rtl === void 0) { - rtl = false; - } - const alignment = getAlignment(placement); - const alignmentAxis = getAlignmentAxis(placement); - const length = getAxisLength(alignmentAxis); - let mainAlignmentSide = alignmentAxis === "x" ? alignment === (rtl ? "end" : "start") ? "right" : "left" : alignment === "start" ? "bottom" : "top"; - if (rects.reference[length] > rects.floating[length]) { - mainAlignmentSide = getOppositePlacement(mainAlignmentSide); - } - return [mainAlignmentSide, getOppositePlacement(mainAlignmentSide)]; - } - function getExpandedPlacements(placement) { - const oppositePlacement = getOppositePlacement(placement); - return [getOppositeAlignmentPlacement(placement), oppositePlacement, getOppositeAlignmentPlacement(oppositePlacement)]; - } - function getOppositeAlignmentPlacement(placement) { - return placement.replace(/start|end/g, (alignment) => oppositeAlignmentMap[alignment]); - } - function getSideList(side, isStart, rtl) { - const lr = ["left", "right"]; - const rl = ["right", "left"]; - const tb = ["top", "bottom"]; - const bt = ["bottom", "top"]; - switch (side) { - case "top": - case "bottom": - if (rtl) return isStart ? rl : lr; - return isStart ? lr : rl; - case "left": - case "right": - return isStart ? tb : bt; - default: - return []; - } - } - function getOppositeAxisPlacements(placement, flipAlignment, direction, rtl) { - const alignment = getAlignment(placement); - let list = getSideList(getSide(placement), direction === "start", rtl); - if (alignment) { - list = list.map((side) => side + "-" + alignment); - if (flipAlignment) { - list = list.concat(list.map(getOppositeAlignmentPlacement)); - } - } - return list; - } - function getOppositePlacement(placement) { - return placement.replace(/left|right|bottom|top/g, (side) => oppositeSideMap[side]); - } - function expandPaddingObject(padding) { - return { - top: 0, - right: 0, - bottom: 0, - left: 0, - ...padding - }; - } - function getPaddingObject(padding) { - return typeof padding !== "number" ? expandPaddingObject(padding) : { - top: padding, - right: padding, - bottom: padding, - left: padding - }; - } - function rectToClientRect(rect) { - const { - x, - y, - width, - height - } = rect; - return { - width, - height, - top: y, - left: x, - right: x + width, - bottom: y + height, - x, - y - }; - } - - // node_modules/@floating-ui/core/dist/floating-ui.core.mjs - function computeCoordsFromPlacement(_ref, placement, rtl) { - let { - reference, - floating - } = _ref; - const sideAxis = getSideAxis(placement); - const alignmentAxis = getAlignmentAxis(placement); - const alignLength = getAxisLength(alignmentAxis); - const side = getSide(placement); - const isVertical = sideAxis === "y"; - const commonX = reference.x + reference.width / 2 - floating.width / 2; - const commonY = reference.y + reference.height / 2 - floating.height / 2; - const commonAlign = reference[alignLength] / 2 - floating[alignLength] / 2; - let coords; - switch (side) { - case "top": - coords = { - x: commonX, - y: reference.y - floating.height - }; - break; - case "bottom": - coords = { - x: commonX, - y: reference.y + reference.height - }; - break; - case "right": - coords = { - x: reference.x + reference.width, - y: commonY - }; - break; - case "left": - coords = { - x: reference.x - floating.width, - y: commonY - }; - break; - default: - coords = { - x: reference.x, - y: reference.y - }; - } - switch (getAlignment(placement)) { - case "start": - coords[alignmentAxis] -= commonAlign * (rtl && isVertical ? -1 : 1); - break; - case "end": - coords[alignmentAxis] += commonAlign * (rtl && isVertical ? -1 : 1); - break; - } - return coords; - } - var computePosition = async (reference, floating, config) => { - const { - placement = "bottom", - strategy = "absolute", - middleware = [], - platform: platform2 - } = config; - const validMiddleware = middleware.filter(Boolean); - const rtl = await (platform2.isRTL == null ? void 0 : platform2.isRTL(floating)); - let rects = await platform2.getElementRects({ - reference, - floating, - strategy - }); - let { - x, - y - } = computeCoordsFromPlacement(rects, placement, rtl); - let statefulPlacement = placement; - let middlewareData = {}; - let resetCount = 0; - for (let i = 0; i < validMiddleware.length; i++) { - const { - name, - fn - } = validMiddleware[i]; - const { - x: nextX, - y: nextY, - data, - reset - } = await fn({ - x, - y, - initialPlacement: placement, - placement: statefulPlacement, - strategy, - middlewareData, - rects, - platform: platform2, - elements: { - reference, - floating - } - }); - x = nextX != null ? nextX : x; - y = nextY != null ? nextY : y; - middlewareData = { - ...middlewareData, - [name]: { - ...middlewareData[name], - ...data - } - }; - if (reset && resetCount <= 50) { - resetCount++; - if (typeof reset === "object") { - if (reset.placement) { - statefulPlacement = reset.placement; - } - if (reset.rects) { - rects = reset.rects === true ? await platform2.getElementRects({ - reference, - floating, - strategy - }) : reset.rects; - } - ({ - x, - y - } = computeCoordsFromPlacement(rects, statefulPlacement, rtl)); - } - i = -1; - } - } - return { - x, - y, - placement: statefulPlacement, - strategy, - middlewareData - }; - }; - async function detectOverflow(state, options) { - var _await$platform$isEle; - if (options === void 0) { - options = {}; - } - const { - x, - y, - platform: platform2, - rects, - elements, - strategy - } = state; - const { - boundary = "clippingAncestors", - rootBoundary = "viewport", - elementContext = "floating", - altBoundary = false, - padding = 0 - } = evaluate(options, state); - const paddingObject = getPaddingObject(padding); - const altContext = elementContext === "floating" ? "reference" : "floating"; - const element2 = elements[altBoundary ? altContext : elementContext]; - const clippingClientRect = rectToClientRect(await platform2.getClippingRect({ - element: ((_await$platform$isEle = await (platform2.isElement == null ? void 0 : platform2.isElement(element2))) != null ? _await$platform$isEle : true) ? element2 : element2.contextElement || await (platform2.getDocumentElement == null ? void 0 : platform2.getDocumentElement(elements.floating)), - boundary, - rootBoundary, - strategy - })); - const rect = elementContext === "floating" ? { - x, - y, - width: rects.floating.width, - height: rects.floating.height - } : rects.reference; - const offsetParent = await (platform2.getOffsetParent == null ? void 0 : platform2.getOffsetParent(elements.floating)); - const offsetScale = await (platform2.isElement == null ? void 0 : platform2.isElement(offsetParent)) ? await (platform2.getScale == null ? void 0 : platform2.getScale(offsetParent)) || { - x: 1, - y: 1 - } : { - x: 1, - y: 1 - }; - const elementClientRect = rectToClientRect(platform2.convertOffsetParentRelativeRectToViewportRelativeRect ? await platform2.convertOffsetParentRelativeRectToViewportRelativeRect({ - elements, - rect, - offsetParent, - strategy - }) : rect); - return { - top: (clippingClientRect.top - elementClientRect.top + paddingObject.top) / offsetScale.y, - bottom: (elementClientRect.bottom - clippingClientRect.bottom + paddingObject.bottom) / offsetScale.y, - left: (clippingClientRect.left - elementClientRect.left + paddingObject.left) / offsetScale.x, - right: (elementClientRect.right - clippingClientRect.right + paddingObject.right) / offsetScale.x - }; - } - var flip = function(options) { - if (options === void 0) { - options = {}; - } - return { - name: "flip", - options, - async fn(state) { - var _middlewareData$arrow, _middlewareData$flip; - const { - placement, - middlewareData, - rects, - initialPlacement, - platform: platform2, - elements - } = state; - const { - mainAxis: checkMainAxis = true, - crossAxis: checkCrossAxis = true, - fallbackPlacements: specifiedFallbackPlacements, - fallbackStrategy = "bestFit", - fallbackAxisSideDirection = "none", - flipAlignment = true, - ...detectOverflowOptions - } = evaluate(options, state); - if ((_middlewareData$arrow = middlewareData.arrow) != null && _middlewareData$arrow.alignmentOffset) { - return {}; - } - const side = getSide(placement); - const initialSideAxis = getSideAxis(initialPlacement); - const isBasePlacement = getSide(initialPlacement) === initialPlacement; - const rtl = await (platform2.isRTL == null ? void 0 : platform2.isRTL(elements.floating)); - const fallbackPlacements = specifiedFallbackPlacements || (isBasePlacement || !flipAlignment ? [getOppositePlacement(initialPlacement)] : getExpandedPlacements(initialPlacement)); - const hasFallbackAxisSideDirection = fallbackAxisSideDirection !== "none"; - if (!specifiedFallbackPlacements && hasFallbackAxisSideDirection) { - fallbackPlacements.push(...getOppositeAxisPlacements(initialPlacement, flipAlignment, fallbackAxisSideDirection, rtl)); - } - const placements2 = [initialPlacement, ...fallbackPlacements]; - const overflow = await detectOverflow(state, detectOverflowOptions); - const overflows = []; - let overflowsData = ((_middlewareData$flip = middlewareData.flip) == null ? void 0 : _middlewareData$flip.overflows) || []; - if (checkMainAxis) { - overflows.push(overflow[side]); - } - if (checkCrossAxis) { - const sides2 = getAlignmentSides(placement, rects, rtl); - overflows.push(overflow[sides2[0]], overflow[sides2[1]]); - } - overflowsData = [...overflowsData, { - placement, - overflows - }]; - if (!overflows.every((side2) => side2 <= 0)) { - var _middlewareData$flip2, _overflowsData$filter; - const nextIndex = (((_middlewareData$flip2 = middlewareData.flip) == null ? void 0 : _middlewareData$flip2.index) || 0) + 1; - const nextPlacement = placements2[nextIndex]; - if (nextPlacement) { - return { - data: { - index: nextIndex, - overflows: overflowsData - }, - reset: { - placement: nextPlacement - } - }; - } - let resetPlacement = (_overflowsData$filter = overflowsData.filter((d) => d.overflows[0] <= 0).sort((a, b) => a.overflows[1] - b.overflows[1])[0]) == null ? void 0 : _overflowsData$filter.placement; - if (!resetPlacement) { - switch (fallbackStrategy) { - case "bestFit": { - var _overflowsData$filter2; - const placement2 = (_overflowsData$filter2 = overflowsData.filter((d) => { - if (hasFallbackAxisSideDirection) { - const currentSideAxis = getSideAxis(d.placement); - return currentSideAxis === initialSideAxis || // Create a bias to the `y` side axis due to horizontal - // reading directions favoring greater width. - currentSideAxis === "y"; - } - return true; - }).map((d) => [d.placement, d.overflows.filter((overflow2) => overflow2 > 0).reduce((acc, overflow2) => acc + overflow2, 0)]).sort((a, b) => a[1] - b[1])[0]) == null ? void 0 : _overflowsData$filter2[0]; - if (placement2) { - resetPlacement = placement2; - } - break; - } - case "initialPlacement": - resetPlacement = initialPlacement; - break; - } - } - if (placement !== resetPlacement) { - return { - reset: { - placement: resetPlacement - } - }; - } - } - return {}; - } - }; - }; - async function convertValueToCoords(state, options) { - const { - placement, - platform: platform2, - elements - } = state; - const rtl = await (platform2.isRTL == null ? void 0 : platform2.isRTL(elements.floating)); - const side = getSide(placement); - const alignment = getAlignment(placement); - const isVertical = getSideAxis(placement) === "y"; - const mainAxisMulti = ["left", "top"].includes(side) ? -1 : 1; - const crossAxisMulti = rtl && isVertical ? -1 : 1; - const rawValue = evaluate(options, state); - let { - mainAxis, - crossAxis, - alignmentAxis - } = typeof rawValue === "number" ? { - mainAxis: rawValue, - crossAxis: 0, - alignmentAxis: null - } : { - mainAxis: rawValue.mainAxis || 0, - crossAxis: rawValue.crossAxis || 0, - alignmentAxis: rawValue.alignmentAxis - }; - if (alignment && typeof alignmentAxis === "number") { - crossAxis = alignment === "end" ? alignmentAxis * -1 : alignmentAxis; - } - return isVertical ? { - x: crossAxis * crossAxisMulti, - y: mainAxis * mainAxisMulti - } : { - x: mainAxis * mainAxisMulti, - y: crossAxis * crossAxisMulti - }; - } - var offset = function(options) { - if (options === void 0) { - options = 0; - } - return { - name: "offset", - options, - async fn(state) { - var _middlewareData$offse, _middlewareData$arrow; - const { - x, - y, - placement, - middlewareData - } = state; - const diffCoords = await convertValueToCoords(state, options); - if (placement === ((_middlewareData$offse = middlewareData.offset) == null ? void 0 : _middlewareData$offse.placement) && (_middlewareData$arrow = middlewareData.arrow) != null && _middlewareData$arrow.alignmentOffset) { - return {}; - } - return { - x: x + diffCoords.x, - y: y + diffCoords.y, - data: { - ...diffCoords, - placement - } - }; - } - }; - }; - var shift = function(options) { - if (options === void 0) { - options = {}; - } - return { - name: "shift", - options, - async fn(state) { - const { - x, - y, - placement - } = state; - const { - mainAxis: checkMainAxis = true, - crossAxis: checkCrossAxis = false, - limiter = { - fn: (_ref) => { - let { - x: x2, - y: y2 - } = _ref; - return { - x: x2, - y: y2 - }; - } - }, - ...detectOverflowOptions - } = evaluate(options, state); - const coords = { - x, - y - }; - const overflow = await detectOverflow(state, detectOverflowOptions); - const crossAxis = getSideAxis(getSide(placement)); - const mainAxis = getOppositeAxis(crossAxis); - let mainAxisCoord = coords[mainAxis]; - let crossAxisCoord = coords[crossAxis]; - if (checkMainAxis) { - const minSide = mainAxis === "y" ? "top" : "left"; - const maxSide = mainAxis === "y" ? "bottom" : "right"; - const min2 = mainAxisCoord + overflow[minSide]; - const max2 = mainAxisCoord - overflow[maxSide]; - mainAxisCoord = clamp(min2, mainAxisCoord, max2); - } - if (checkCrossAxis) { - const minSide = crossAxis === "y" ? "top" : "left"; - const maxSide = crossAxis === "y" ? "bottom" : "right"; - const min2 = crossAxisCoord + overflow[minSide]; - const max2 = crossAxisCoord - overflow[maxSide]; - crossAxisCoord = clamp(min2, crossAxisCoord, max2); - } - const limitedCoords = limiter.fn({ - ...state, - [mainAxis]: mainAxisCoord, - [crossAxis]: crossAxisCoord - }); - return { - ...limitedCoords, - data: { - x: limitedCoords.x - x, - y: limitedCoords.y - y, - enabled: { - [mainAxis]: checkMainAxis, - [crossAxis]: checkCrossAxis - } - } - }; - } - }; - }; - var size = function(options) { - if (options === void 0) { - options = {}; - } - return { - name: "size", - options, - async fn(state) { - var _state$middlewareData, _state$middlewareData2; - const { - placement, - rects, - platform: platform2, - elements - } = state; - const { - apply: apply3 = () => { - }, - ...detectOverflowOptions - } = evaluate(options, state); - const overflow = await detectOverflow(state, detectOverflowOptions); - const side = getSide(placement); - const alignment = getAlignment(placement); - const isYAxis = getSideAxis(placement) === "y"; - const { - width, - height - } = rects.floating; - let heightSide; - let widthSide; - if (side === "top" || side === "bottom") { - heightSide = side; - widthSide = alignment === (await (platform2.isRTL == null ? void 0 : platform2.isRTL(elements.floating)) ? "start" : "end") ? "left" : "right"; - } else { - widthSide = side; - heightSide = alignment === "end" ? "top" : "bottom"; - } - const maximumClippingHeight = height - overflow.top - overflow.bottom; - const maximumClippingWidth = width - overflow.left - overflow.right; - const overflowAvailableHeight = min(height - overflow[heightSide], maximumClippingHeight); - const overflowAvailableWidth = min(width - overflow[widthSide], maximumClippingWidth); - const noShift = !state.middlewareData.shift; - let availableHeight = overflowAvailableHeight; - let availableWidth = overflowAvailableWidth; - if ((_state$middlewareData = state.middlewareData.shift) != null && _state$middlewareData.enabled.x) { - availableWidth = maximumClippingWidth; - } - if ((_state$middlewareData2 = state.middlewareData.shift) != null && _state$middlewareData2.enabled.y) { - availableHeight = maximumClippingHeight; - } - if (noShift && !alignment) { - const xMin = max(overflow.left, 0); - const xMax = max(overflow.right, 0); - const yMin = max(overflow.top, 0); - const yMax = max(overflow.bottom, 0); - if (isYAxis) { - availableWidth = width - 2 * (xMin !== 0 || xMax !== 0 ? xMin + xMax : max(overflow.left, overflow.right)); - } else { - availableHeight = height - 2 * (yMin !== 0 || yMax !== 0 ? yMin + yMax : max(overflow.top, overflow.bottom)); - } - } - await apply3({ - ...state, - availableWidth, - availableHeight - }); - const nextDimensions = await platform2.getDimensions(elements.floating); - if (width !== nextDimensions.width || height !== nextDimensions.height) { - return { - reset: { - rects: true - } - }; - } - return {}; - } - }; - }; - - // node_modules/@floating-ui/utils/dist/floating-ui.utils.dom.mjs - function hasWindow() { - return typeof window !== "undefined"; - } - function getNodeName(node) { - if (isNode(node)) { - return (node.nodeName || "").toLowerCase(); - } - return "#document"; - } - function getWindow(node) { - var _node$ownerDocument; - return (node == null || (_node$ownerDocument = node.ownerDocument) == null ? void 0 : _node$ownerDocument.defaultView) || window; - } - function getDocumentElement(node) { - var _ref; - return (_ref = (isNode(node) ? node.ownerDocument : node.document) || window.document) == null ? void 0 : _ref.documentElement; - } - function isNode(value3) { - if (!hasWindow()) { - return false; - } - return value3 instanceof Node || value3 instanceof getWindow(value3).Node; - } - function isElement(value3) { - if (!hasWindow()) { - return false; - } - return value3 instanceof Element || value3 instanceof getWindow(value3).Element; - } - function isHTMLElement(value3) { - if (!hasWindow()) { - return false; - } - return value3 instanceof HTMLElement || value3 instanceof getWindow(value3).HTMLElement; - } - function isShadowRoot(value3) { - if (!hasWindow() || typeof ShadowRoot === "undefined") { - return false; - } - return value3 instanceof ShadowRoot || value3 instanceof getWindow(value3).ShadowRoot; - } - function isOverflowElement(element2) { - const { - overflow, - overflowX, - overflowY, - display - } = getComputedStyle2(element2); - return /auto|scroll|overlay|hidden|clip/.test(overflow + overflowY + overflowX) && !["inline", "contents"].includes(display); - } - function isTableElement(element2) { - return ["table", "td", "th"].includes(getNodeName(element2)); - } - function isTopLayer(element2) { - return [":popover-open", ":modal"].some((selector) => { - try { - return element2.matches(selector); - } catch (e) { - return false; - } - }); - } - function isContainingBlock(elementOrCss) { - const webkit = isWebKit(); - const css = isElement(elementOrCss) ? getComputedStyle2(elementOrCss) : elementOrCss; - return ["transform", "translate", "scale", "rotate", "perspective"].some((value3) => css[value3] ? css[value3] !== "none" : false) || (css.containerType ? css.containerType !== "normal" : false) || !webkit && (css.backdropFilter ? css.backdropFilter !== "none" : false) || !webkit && (css.filter ? css.filter !== "none" : false) || ["transform", "translate", "scale", "rotate", "perspective", "filter"].some((value3) => (css.willChange || "").includes(value3)) || ["paint", "layout", "strict", "content"].some((value3) => (css.contain || "").includes(value3)); - } - function getContainingBlock(element2) { - let currentNode = getParentNode(element2); - while (isHTMLElement(currentNode) && !isLastTraversableNode(currentNode)) { - if (isContainingBlock(currentNode)) { - return currentNode; - } else if (isTopLayer(currentNode)) { - return null; - } - currentNode = getParentNode(currentNode); - } - return null; - } - function isWebKit() { - if (typeof CSS === "undefined" || !CSS.supports) return false; - return CSS.supports("-webkit-backdrop-filter", "none"); - } - function isLastTraversableNode(node) { - return ["html", "body", "#document"].includes(getNodeName(node)); - } - function getComputedStyle2(element2) { - return getWindow(element2).getComputedStyle(element2); - } - function getNodeScroll(element2) { - if (isElement(element2)) { - return { - scrollLeft: element2.scrollLeft, - scrollTop: element2.scrollTop - }; - } - return { - scrollLeft: element2.scrollX, - scrollTop: element2.scrollY - }; - } - function getParentNode(node) { - if (getNodeName(node) === "html") { - return node; - } - const result = ( - // Step into the shadow DOM of the parent of a slotted node. - node.assignedSlot || // DOM Element detected. - node.parentNode || // ShadowRoot detected. - isShadowRoot(node) && node.host || // Fallback. - getDocumentElement(node) - ); - return isShadowRoot(result) ? result.host : result; - } - function getNearestOverflowAncestor(node) { - const parentNode = getParentNode(node); - if (isLastTraversableNode(parentNode)) { - return node.ownerDocument ? node.ownerDocument.body : node.body; - } - if (isHTMLElement(parentNode) && isOverflowElement(parentNode)) { - return parentNode; - } - return getNearestOverflowAncestor(parentNode); - } - function getOverflowAncestors(node, list, traverseIframes) { - var _node$ownerDocument2; - if (list === void 0) { - list = []; - } - if (traverseIframes === void 0) { - traverseIframes = true; - } - const scrollableAncestor = getNearestOverflowAncestor(node); - const isBody = scrollableAncestor === ((_node$ownerDocument2 = node.ownerDocument) == null ? void 0 : _node$ownerDocument2.body); - const win = getWindow(scrollableAncestor); - if (isBody) { - const frameElement = getFrameElement(win); - return list.concat(win, win.visualViewport || [], isOverflowElement(scrollableAncestor) ? scrollableAncestor : [], frameElement && traverseIframes ? getOverflowAncestors(frameElement) : []); - } - return list.concat(scrollableAncestor, getOverflowAncestors(scrollableAncestor, [], traverseIframes)); - } - function getFrameElement(win) { - return win.parent && Object.getPrototypeOf(win.parent) ? win.frameElement : null; - } - - // node_modules/@floating-ui/dom/dist/floating-ui.dom.mjs - function getCssDimensions(element2) { - const css = getComputedStyle2(element2); - let width = parseFloat(css.width) || 0; - let height = parseFloat(css.height) || 0; - const hasOffset = isHTMLElement(element2); - const offsetWidth = hasOffset ? element2.offsetWidth : width; - const offsetHeight = hasOffset ? element2.offsetHeight : height; - const shouldFallback = round(width) !== offsetWidth || round(height) !== offsetHeight; - if (shouldFallback) { - width = offsetWidth; - height = offsetHeight; - } - return { - width, - height, - $: shouldFallback - }; - } - function unwrapElement(element2) { - return !isElement(element2) ? element2.contextElement : element2; - } - function getScale(element2) { - const domElement = unwrapElement(element2); - if (!isHTMLElement(domElement)) { - return createCoords(1); - } - const rect = domElement.getBoundingClientRect(); - const { - width, - height, - $ - } = getCssDimensions(domElement); - let x = ($ ? round(rect.width) : rect.width) / width; - let y = ($ ? round(rect.height) : rect.height) / height; - if (!x || !Number.isFinite(x)) { - x = 1; - } - if (!y || !Number.isFinite(y)) { - y = 1; - } - return { - x, - y - }; - } - var noOffsets = /* @__PURE__ */ createCoords(0); - function getVisualOffsets(element2) { - const win = getWindow(element2); - if (!isWebKit() || !win.visualViewport) { - return noOffsets; - } - return { - x: win.visualViewport.offsetLeft, - y: win.visualViewport.offsetTop - }; - } - function shouldAddVisualOffsets(element2, isFixed, floatingOffsetParent) { - if (isFixed === void 0) { - isFixed = false; - } - if (!floatingOffsetParent || isFixed && floatingOffsetParent !== getWindow(element2)) { - return false; - } - return isFixed; - } - function getBoundingClientRect(element2, includeScale, isFixedStrategy, offsetParent) { - if (includeScale === void 0) { - includeScale = false; - } - if (isFixedStrategy === void 0) { - isFixedStrategy = false; - } - const clientRect = element2.getBoundingClientRect(); - const domElement = unwrapElement(element2); - let scale = createCoords(1); - if (includeScale) { - if (offsetParent) { - if (isElement(offsetParent)) { - scale = getScale(offsetParent); - } - } else { - scale = getScale(element2); - } - } - const visualOffsets = shouldAddVisualOffsets(domElement, isFixedStrategy, offsetParent) ? getVisualOffsets(domElement) : createCoords(0); - let x = (clientRect.left + visualOffsets.x) / scale.x; - let y = (clientRect.top + visualOffsets.y) / scale.y; - let width = clientRect.width / scale.x; - let height = clientRect.height / scale.y; - if (domElement) { - const win = getWindow(domElement); - const offsetWin = offsetParent && isElement(offsetParent) ? getWindow(offsetParent) : offsetParent; - let currentWin = win; - let currentIFrame = getFrameElement(currentWin); - while (currentIFrame && offsetParent && offsetWin !== currentWin) { - const iframeScale = getScale(currentIFrame); - const iframeRect = currentIFrame.getBoundingClientRect(); - const css = getComputedStyle2(currentIFrame); - const left = iframeRect.left + (currentIFrame.clientLeft + parseFloat(css.paddingLeft)) * iframeScale.x; - const top = iframeRect.top + (currentIFrame.clientTop + parseFloat(css.paddingTop)) * iframeScale.y; - x *= iframeScale.x; - y *= iframeScale.y; - width *= iframeScale.x; - height *= iframeScale.y; - x += left; - y += top; - currentWin = getWindow(currentIFrame); - currentIFrame = getFrameElement(currentWin); - } - } - return rectToClientRect({ - width, - height, - x, - y - }); - } - function getWindowScrollBarX(element2, rect) { - const leftScroll = getNodeScroll(element2).scrollLeft; - if (!rect) { - return getBoundingClientRect(getDocumentElement(element2)).left + leftScroll; - } - return rect.left + leftScroll; - } - function getHTMLOffset(documentElement, scroll, ignoreScrollbarX) { - if (ignoreScrollbarX === void 0) { - ignoreScrollbarX = false; - } - const htmlRect = documentElement.getBoundingClientRect(); - const x = htmlRect.left + scroll.scrollLeft - (ignoreScrollbarX ? 0 : ( - // RTL scrollbar. - getWindowScrollBarX(documentElement, htmlRect) - )); - const y = htmlRect.top + scroll.scrollTop; - return { - x, - y - }; - } - function convertOffsetParentRelativeRectToViewportRelativeRect(_ref) { - let { - elements, - rect, - offsetParent, - strategy - } = _ref; - const isFixed = strategy === "fixed"; - const documentElement = getDocumentElement(offsetParent); - const topLayer = elements ? isTopLayer(elements.floating) : false; - if (offsetParent === documentElement || topLayer && isFixed) { - return rect; - } - let scroll = { - scrollLeft: 0, - scrollTop: 0 - }; - let scale = createCoords(1); - const offsets = createCoords(0); - const isOffsetParentAnElement = isHTMLElement(offsetParent); - if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) { - if (getNodeName(offsetParent) !== "body" || isOverflowElement(documentElement)) { - scroll = getNodeScroll(offsetParent); - } - if (isHTMLElement(offsetParent)) { - const offsetRect = getBoundingClientRect(offsetParent); - scale = getScale(offsetParent); - offsets.x = offsetRect.x + offsetParent.clientLeft; - offsets.y = offsetRect.y + offsetParent.clientTop; - } - } - const htmlOffset = documentElement && !isOffsetParentAnElement && !isFixed ? getHTMLOffset(documentElement, scroll, true) : createCoords(0); - return { - width: rect.width * scale.x, - height: rect.height * scale.y, - x: rect.x * scale.x - scroll.scrollLeft * scale.x + offsets.x + htmlOffset.x, - y: rect.y * scale.y - scroll.scrollTop * scale.y + offsets.y + htmlOffset.y - }; - } - function getClientRects(element2) { - return Array.from(element2.getClientRects()); - } - function getDocumentRect(element2) { - const html = getDocumentElement(element2); - const scroll = getNodeScroll(element2); - const body = element2.ownerDocument.body; - const width = max(html.scrollWidth, html.clientWidth, body.scrollWidth, body.clientWidth); - const height = max(html.scrollHeight, html.clientHeight, body.scrollHeight, body.clientHeight); - let x = -scroll.scrollLeft + getWindowScrollBarX(element2); - const y = -scroll.scrollTop; - if (getComputedStyle2(body).direction === "rtl") { - x += max(html.clientWidth, body.clientWidth) - width; - } - return { - width, - height, - x, - y - }; - } - function getViewportRect(element2, strategy) { - const win = getWindow(element2); - const html = getDocumentElement(element2); - const visualViewport = win.visualViewport; - let width = html.clientWidth; - let height = html.clientHeight; - let x = 0; - let y = 0; - if (visualViewport) { - width = visualViewport.width; - height = visualViewport.height; - const visualViewportBased = isWebKit(); - if (!visualViewportBased || visualViewportBased && strategy === "fixed") { - x = visualViewport.offsetLeft; - y = visualViewport.offsetTop; - } - } - return { - width, - height, - x, - y - }; - } - function getInnerBoundingClientRect(element2, strategy) { - const clientRect = getBoundingClientRect(element2, true, strategy === "fixed"); - const top = clientRect.top + element2.clientTop; - const left = clientRect.left + element2.clientLeft; - const scale = isHTMLElement(element2) ? getScale(element2) : createCoords(1); - const width = element2.clientWidth * scale.x; - const height = element2.clientHeight * scale.y; - const x = left * scale.x; - const y = top * scale.y; - return { - width, - height, - x, - y - }; - } - function getClientRectFromClippingAncestor(element2, clippingAncestor, strategy) { - let rect; - if (clippingAncestor === "viewport") { - rect = getViewportRect(element2, strategy); - } else if (clippingAncestor === "document") { - rect = getDocumentRect(getDocumentElement(element2)); - } else if (isElement(clippingAncestor)) { - rect = getInnerBoundingClientRect(clippingAncestor, strategy); - } else { - const visualOffsets = getVisualOffsets(element2); - rect = { - x: clippingAncestor.x - visualOffsets.x, - y: clippingAncestor.y - visualOffsets.y, - width: clippingAncestor.width, - height: clippingAncestor.height - }; - } - return rectToClientRect(rect); - } - function hasFixedPositionAncestor(element2, stopNode) { - const parentNode = getParentNode(element2); - if (parentNode === stopNode || !isElement(parentNode) || isLastTraversableNode(parentNode)) { - return false; - } - return getComputedStyle2(parentNode).position === "fixed" || hasFixedPositionAncestor(parentNode, stopNode); - } - function getClippingElementAncestors(element2, cache) { - const cachedResult = cache.get(element2); - if (cachedResult) { - return cachedResult; - } - let result = getOverflowAncestors(element2, [], false).filter((el) => isElement(el) && getNodeName(el) !== "body"); - let currentContainingBlockComputedStyle = null; - const elementIsFixed = getComputedStyle2(element2).position === "fixed"; - let currentNode = elementIsFixed ? getParentNode(element2) : element2; - while (isElement(currentNode) && !isLastTraversableNode(currentNode)) { - const computedStyle = getComputedStyle2(currentNode); - const currentNodeIsContaining = isContainingBlock(currentNode); - if (!currentNodeIsContaining && computedStyle.position === "fixed") { - currentContainingBlockComputedStyle = null; - } - const shouldDropCurrentNode = elementIsFixed ? !currentNodeIsContaining && !currentContainingBlockComputedStyle : !currentNodeIsContaining && computedStyle.position === "static" && !!currentContainingBlockComputedStyle && ["absolute", "fixed"].includes(currentContainingBlockComputedStyle.position) || isOverflowElement(currentNode) && !currentNodeIsContaining && hasFixedPositionAncestor(element2, currentNode); - if (shouldDropCurrentNode) { - result = result.filter((ancestor) => ancestor !== currentNode); - } else { - currentContainingBlockComputedStyle = computedStyle; - } - currentNode = getParentNode(currentNode); - } - cache.set(element2, result); - return result; - } - function getClippingRect(_ref) { - let { - element: element2, - boundary, - rootBoundary, - strategy - } = _ref; - const elementClippingAncestors = boundary === "clippingAncestors" ? isTopLayer(element2) ? [] : getClippingElementAncestors(element2, this._c) : [].concat(boundary); - const clippingAncestors = [...elementClippingAncestors, rootBoundary]; - const firstClippingAncestor = clippingAncestors[0]; - const clippingRect = clippingAncestors.reduce((accRect, clippingAncestor) => { - const rect = getClientRectFromClippingAncestor(element2, clippingAncestor, strategy); - accRect.top = max(rect.top, accRect.top); - accRect.right = min(rect.right, accRect.right); - accRect.bottom = min(rect.bottom, accRect.bottom); - accRect.left = max(rect.left, accRect.left); - return accRect; - }, getClientRectFromClippingAncestor(element2, firstClippingAncestor, strategy)); - return { - width: clippingRect.right - clippingRect.left, - height: clippingRect.bottom - clippingRect.top, - x: clippingRect.left, - y: clippingRect.top - }; - } - function getDimensions(element2) { - const { - width, - height - } = getCssDimensions(element2); - return { - width, - height - }; - } - function getRectRelativeToOffsetParent(element2, offsetParent, strategy) { - const isOffsetParentAnElement = isHTMLElement(offsetParent); - const documentElement = getDocumentElement(offsetParent); - const isFixed = strategy === "fixed"; - const rect = getBoundingClientRect(element2, true, isFixed, offsetParent); - let scroll = { - scrollLeft: 0, - scrollTop: 0 - }; - const offsets = createCoords(0); - if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) { - if (getNodeName(offsetParent) !== "body" || isOverflowElement(documentElement)) { - scroll = getNodeScroll(offsetParent); - } - if (isOffsetParentAnElement) { - const offsetRect = getBoundingClientRect(offsetParent, true, isFixed, offsetParent); - offsets.x = offsetRect.x + offsetParent.clientLeft; - offsets.y = offsetRect.y + offsetParent.clientTop; - } else if (documentElement) { - offsets.x = getWindowScrollBarX(documentElement); - } - } - const htmlOffset = documentElement && !isOffsetParentAnElement && !isFixed ? getHTMLOffset(documentElement, scroll) : createCoords(0); - const x = rect.left + scroll.scrollLeft - offsets.x - htmlOffset.x; - const y = rect.top + scroll.scrollTop - offsets.y - htmlOffset.y; - return { - x, - y, - width: rect.width, - height: rect.height - }; - } - function isStaticPositioned(element2) { - return getComputedStyle2(element2).position === "static"; - } - function getTrueOffsetParent(element2, polyfill) { - if (!isHTMLElement(element2) || getComputedStyle2(element2).position === "fixed") { - return null; - } - if (polyfill) { - return polyfill(element2); - } - let rawOffsetParent = element2.offsetParent; - if (getDocumentElement(element2) === rawOffsetParent) { - rawOffsetParent = rawOffsetParent.ownerDocument.body; - } - return rawOffsetParent; - } - function getOffsetParent(element2, polyfill) { - const win = getWindow(element2); - if (isTopLayer(element2)) { - return win; - } - if (!isHTMLElement(element2)) { - let svgOffsetParent = getParentNode(element2); - while (svgOffsetParent && !isLastTraversableNode(svgOffsetParent)) { - if (isElement(svgOffsetParent) && !isStaticPositioned(svgOffsetParent)) { - return svgOffsetParent; - } - svgOffsetParent = getParentNode(svgOffsetParent); - } - return win; - } - let offsetParent = getTrueOffsetParent(element2, polyfill); - while (offsetParent && isTableElement(offsetParent) && isStaticPositioned(offsetParent)) { - offsetParent = getTrueOffsetParent(offsetParent, polyfill); - } - if (offsetParent && isLastTraversableNode(offsetParent) && isStaticPositioned(offsetParent) && !isContainingBlock(offsetParent)) { - return win; - } - return offsetParent || getContainingBlock(element2) || win; - } - var getElementRects = async function(data) { - const getOffsetParentFn = this.getOffsetParent || getOffsetParent; - const getDimensionsFn = this.getDimensions; - const floatingDimensions = await getDimensionsFn(data.floating); - return { - reference: getRectRelativeToOffsetParent(data.reference, await getOffsetParentFn(data.floating), data.strategy), - floating: { - x: 0, - y: 0, - width: floatingDimensions.width, - height: floatingDimensions.height - } - }; - }; - function isRTL2(element2) { - return getComputedStyle2(element2).direction === "rtl"; - } - var platform = { - convertOffsetParentRelativeRectToViewportRelativeRect, - getDocumentElement, - getClippingRect, - getOffsetParent, - getElementRects, - getClientRects, - getDimensions, - getScale, - isElement, - isRTL: isRTL2 - }; - function rectsAreEqual(a, b) { - return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; - } - function observeMove(element2, onMove) { - let io = null; - let timeoutId; - const root = getDocumentElement(element2); - function cleanup() { - var _io; - clearTimeout(timeoutId); - (_io = io) == null || _io.disconnect(); - io = null; - } - function refresh(skip, threshold) { - if (skip === void 0) { - skip = false; - } - if (threshold === void 0) { - threshold = 1; - } - cleanup(); - const elementRectForRootMargin = element2.getBoundingClientRect(); - const { - left, - top, - width, - height - } = elementRectForRootMargin; - if (!skip) { - onMove(); - } - if (!width || !height) { - return; - } - const insetTop = floor(top); - const insetRight = floor(root.clientWidth - (left + width)); - const insetBottom = floor(root.clientHeight - (top + height)); - const insetLeft = floor(left); - const rootMargin = -insetTop + "px " + -insetRight + "px " + -insetBottom + "px " + -insetLeft + "px"; - const options = { - rootMargin, - threshold: max(0, min(1, threshold)) || 1 - }; - let isFirstUpdate = true; - function handleObserve(entries) { - const ratio = entries[0].intersectionRatio; - if (ratio !== threshold) { - if (!isFirstUpdate) { - return refresh(); - } - if (!ratio) { - timeoutId = setTimeout(() => { - refresh(false, 1e-7); - }, 1e3); - } else { - refresh(false, ratio); - } - } - if (ratio === 1 && !rectsAreEqual(elementRectForRootMargin, element2.getBoundingClientRect())) { - refresh(); - } - isFirstUpdate = false; - } - try { - io = new IntersectionObserver(handleObserve, { - ...options, - // Handle + + @unless($itemId) +
+
+ + {{ __('Die Vorschau erscheint, sobald der Inhalt gespeichert wurde.') }} +
+
+ @endunless - - - {{ __('Vollbild öffnen') }} - + @if($itemId) + + + {{ __('Vollbild öffnen') }} + + @endif
diff --git a/resources/views/livewire/admin/cms/display-version-list.blade.php b/resources/views/livewire/admin/cms/display-version-list.blade.php index 69866d0..4caecae 100644 --- a/resources/views/livewire/admin/cms/display-version-list.blade.php +++ b/resources/views/livewire/admin/cms/display-version-list.blade.php @@ -10,6 +10,15 @@ @endif + @if (session()->has('error')) +
+
+ +

{{ session('error') }}

+
+
+ @endif +
diff --git a/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php index 29eccd3..dfbc8e6 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-offers.blade.php @@ -6,18 +6,18 @@
- {{ __('Slides') }} - {{ __('Angebots-Slides werden in der angegebenen Reihenfolge angezeigt') }} + {{ __('Angebote') }} + {{ __('Angebote werden im einheitlichen Detail-Layout in der angegebenen Reihenfolge angezeigt') }}
- {{ __('Slide hinzufügen') }} + {{ __('Angebot hinzufügen') }}
@if($slides->isEmpty())
-

{{ __('Noch keine Slides vorhanden.') }}

+

{{ __('Noch keine Angebote vorhanden.') }}

@else
@@ -42,29 +42,30 @@
+ @php + $c = $item->content; + $enabledBlocks = collect([ + 'Badge' => ($c['show_badge'] ?? ! empty($c['badge_text'])) && ! empty($c['badge_text']), + 'Aufzählung' => ($c['show_bullets'] ?? ! empty($c['bullets'])) && ! empty($c['bullets']), + 'Preis' => ($c['show_price'] ?? ! empty($c['price'])) && ! empty($c['price']), + 'QR' => ($c['show_qr'] ?? ! empty($c['qr_url'])) && ! empty($c['qr_url']), + 'Kontakt' => ($c['show_contact'] ?? ! empty($c['contact'])) && ! empty($c['contact']), + ])->filter()->keys(); + @endphp
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} - - {{ match($item->content['type'] ?? '') { - 'intro' => 'Intro', - 'product-hero' => 'Produkt-Hero', - 'product-details' => 'Produkt-Details', - 'product-impulse' => 'Produkt-Impuls', - default => $item->content['type'] ?? '–', - } }} - - {{ $item->content['title'] ?? '–' }} + {{ $c['title'] ?? '–' }}
-
- @if(!empty($item->content['price'])) - {{ $item->content['price'] }} - @endif - {{ number_format(($item->content['duration'] ?? 8000) / 1000, 1) }}s - @if(!empty($item->content['badge_text'])) - {{ $item->content['badge_text'] }} +
+ {{ number_format(($c['duration'] ?? 8000) / 1000, 1) }}s + @if(!empty($c['price']) && ($c['show_price'] ?? ! empty($c['price']))) + {{ $c['price'] }} @endif + @foreach($enabledBlocks as $block) + {{ $block }} + @endforeach
diff --git a/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php index 91180e8..ceda105 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php @@ -1,25 +1,74 @@ @if($version->type->value === 'b2in') + @php + $footerShown = ($settings['show_footer'] ?? true) !== false; + $showLogo = ($settings['show_logo'] ?? true) !== false; + $showClaim = ($settings['show_claim'] ?? true) !== false; + $logoPos = $settings['logo_position'] ?? 'top-left'; + $claimPos = $settings['claim_position'] ?? 'top-right'; + $brandPositions = [ + 'top-left' => __('Oben links'), + 'top-right' => __('Oben rechts'), + 'bottom-left' => __('Unten links'), + 'bottom-right' => __('Unten rechts'), + ]; + @endphp
- {{ __('Header') }} + {{ __('Marke') }} + {{ __('Logo und Claim. Standardmäßig oben im Header. Die Ecken lassen sich frei wählen.') }} - +
+ + +
+ +
+ + +
+ + @if($showLogo) + + @foreach($brandPositions as $value => $label) + + @endforeach + + @endif + + @if($showClaim) + + @foreach($brandPositions as $value => $label) + + @endforeach + + @endif + + @if($footerShown) + + {{ __('Untere Ecken sind nur verfügbar, wenn der Footer ausgeblendet ist.') }} + + @endif
{{ __('Footer & QR') }} + - - +
@@ -32,30 +81,14 @@ @elseif($version->type->value === 'offers') -
- {{ __('Branding') }} - - - -
-
- {{ __('Footer & QR für alle Slides') }} - - - - -
+ + {{ __('Logo, Marken-Text, QR-Code und Kontakt werden je Angebot direkt am Element gepflegt.') }} + @elseif($version->type->value === 'video-display') @endif diff --git a/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php index 4d727c3..53f59ae 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php @@ -16,40 +16,43 @@
- @if($videos->isEmpty()) + @if ($videos->isEmpty())

{{ __('Noch keine Videos vorhanden.') }}

@else
- @foreach($videos as $index => $item) + @foreach ($videos as $index => $item)
+ class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
- @if($index > 0) - + @if ($index > 0) + @endif - @if($index < count($videos) - 1) - + @if ($index < count($videos) - 1) + + @endif
-
- Video -
+
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }} - {{ $item->content['title'] ?? $item->content['filename'] ?? '–' }} + {{ $item->content['title'] ?? ($item->content['filename'] ?? '–') }}
@php $videoSource = $item->content['filename'] ?? ''; - $isMediaLibrarySource = str_starts_with($videoSource, '/storage/') || str_starts_with($videoSource, 'http'); + $isMediaLibrarySource = + str_starts_with($videoSource, '/storage/') || str_starts_with($videoSource, 'http'); @endphp
@@ -61,9 +64,13 @@
- - - + + +
@endforeach @@ -76,34 +83,40 @@
{{ __('Footer-Inhalte') }} - {{ __('Inhalte werden im Footer rotiert') }} + {{ __('Inhalte werden im Footer rotiert / ohne Inhalte bleibt der untere Teil frei.') }} +
{{ __('Inhalt hinzufügen') }}
- @if($footers->isEmpty()) + @if ($footers->isEmpty())

{{ __('Noch keine Footer-Inhalte vorhanden.') }}

@else
- @foreach($footers as $index => $item) + @foreach ($footers as $index => $item)
+ class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
- @if($index > 0) - + @if ($index > 0) + + @endif - @if($index < count($footers) - 1) - + @if ($index < count($footers) - 1) + + @endif
-
+
{{ $item->content['headline'] ?? 'Footer' }}
{{ $item->content['subline'] ?? '' }}
@@ -117,16 +130,20 @@
{{ $item->content['subline'] ?? '' }} - @if(!empty($item->content['url'])) + @if (!empty($item->content['url'])) {{ Str::limit($item->content['url'], 40) }} @endif
- - - + + +
@endforeach diff --git a/resources/views/livewire/admin/hubs/manage.blade.php b/resources/views/livewire/admin/hubs/manage.blade.php index 59cb86f..25aa71b 100644 --- a/resources/views/livewire/admin/hubs/manage.blade.php +++ b/resources/views/livewire/admin/hubs/manage.blade.php @@ -35,26 +35,26 @@ mount(function ($hubId = null) { // Auto-generate slug from name $updatedName = function ($value) { - if (!$this->hubId) { // Only auto-generate for new hubs + if (!$this->hubId) { + // Only auto-generate for new hubs $this->slug = \Illuminate\Support\Str::slug($value); } }; $locations = computed(function () { - if (!$this->hubId) return collect(); + if (!$this->hubId) { + return collect(); + } - return \App\Models\HubLocation::where('hub_id', $this->hubId) - ->when($this->zipSearch, fn($q) => $q->where('zip_code', 'like', "%{$this->zipSearch}%") - ->orWhere('city_name', 'like', "%{$this->zipSearch}%")) - ->orderBy('zip_code') - ->paginate(50); + return \App\Models\HubLocation::where('hub_id', $this->hubId)->when($this->zipSearch, fn($q) => $q->where('zip_code', 'like', "%{$this->zipSearch}%")->orWhere('city_name', 'like', "%{$this->zipSearch}%"))->orderBy('zip_code')->paginate(50); }); $partners = computed(function () { - if (!$this->hubId) return collect(); + if (!$this->hubId) { + return collect(); + } - return \App\Models\Partner::where('hub_id', $this->hubId) - ->get(); + return \App\Models\Partner::where('hub_id', $this->hubId)->get(); }); // Dummy save function @@ -103,12 +103,12 @@ $deleteLocation = function ($id) { {{-- Flash Message --}} @if (session()->has('message')) - -
- - {{ session('message') }} -
-
+ +
+ + {{ session('message') }} +
+
@endif {{-- Tabs --}} @@ -119,364 +119,360 @@ $deleteLocation = function ($id) { {{-- TAB 1: Identität & Design --}} - @if($activeTab === 'identity') - -
- {{-- Basis-Informationen --}} -
- - {{ __('Hub-Name') }} * - - {{ __('Wird dem Kunden angezeigt, z.B. "Region Ostwestfalen-Lippe"') }} - + @if ($activeTab === 'identity') + +
+ {{-- Basis-Informationen --}} +
+ + {{ __('Hub-Name') }} * + + {{ __('Wird dem Kunden angezeigt, z.B. "Region Ostwestfalen-Lippe"') }} + + - - {{ __('URL-Slug') }} * - - {{ __('Für saubere URLs, z.B. b2in.de/region/owl') }} - + + {{ __('URL-Slug') }} * + + {{ __('Für saubere URLs, z.B. b2in.eu/region/owl') }} + - - {{ __('Status') }} - - {{ __('Hub ist aktiv und für Kunden sichtbar') }} - - - {{ __('Inaktive Hubs sind im "Coming Soon"-Modus') }} - - -
+ + {{ __('Status') }} + + {{ __('Hub ist aktiv und für Kunden sichtbar') }} + + + {{ __('Inaktive Hubs sind im "Coming Soon"-Modus') }} + + +
- {{-- Vorschau --}} -
-

{{ __('Vorschau: Kunden-Landingpage') }}

-
- @if($keyvisual) - Keyvisual - @else -
-
- -

{{ __('Keyvisual hochladen') }}

+ {{-- Vorschau --}} +
+

+ {{ __('Vorschau: Kunden-Landingpage') }}

+
+ @if ($keyvisual) + Keyvisual + @else +
+
+ +

{{ __('Keyvisual hochladen') }}

+
+
+ @endif +
+

+ {{ $name ?: __('Ihr Hub-Name') }} +

+

+ {{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }} +

+ @if ($emblem) +
+ Emblem +
+ @endif
- @endif -
-

- {{ $name ?: __('Ihr Hub-Name') }} -

-

- {{ __('Die besten Marken Europas, von Händlern aus Ihrer Nachbarschaft') }} -

-
- @if($emblem) -
- Emblem -
- @endif
-
- + - {{-- Keyvisual Upload --}} - - {{ __('Keyvisual (Hintergrundbild)') }} - {{-- --}} - - {{ __('Atmosphärisches Regionalbild für emotionalen Einstieg') }} -
- - {{ __('Beispiele: Hermannsdenkmal (OWL), Skyline (Frankfurt), Hafen (Hamburg)') }} - • {{ __('Empfohlen: 1920x800px, max. 2MB') }} - -
-
+ {{-- Keyvisual Upload --}} + + {{ __('Keyvisual (Hintergrundbild)') }} + {{-- --}} + + {{ __('Atmosphärisches Regionalbild für emotionalen Einstieg') }} +
+ + {{ __('Beispiele: Hermannsdenkmal (OWL), Skyline (Frankfurt), Hafen (Hamburg)') }} + • {{ __('Empfohlen: 1920x800px, max. 2MB') }} + +
+
- {{-- Wappen Upload --}} - - {{ __('Wappen / Emblem') }} - {{-- --}} - - {{ __('Offizielles Wappen oder Logo der Region für Vertrauen & Offizialität') }} -
- - {{ __('Beispiele: Sparrenburg-Logo, Landeswappen') }} - • {{ __('Empfohlen: 256x256px, PNG mit transparentem Hintergrund') }} - -
-
- + {{-- Wappen Upload --}} + + {{ __('Wappen / Emblem') }} + {{-- --}} + + {{ __('Offizielles Wappen oder Logo der Region für Vertrauen & Offizialität') }} +
+ + {{ __('Beispiele: Sparrenburg-Logo, Landeswappen') }} + • {{ __('Empfohlen: 256x256px, PNG mit transparentem Hintergrund') }} + +
+
+ @endif {{-- TAB 2: Geografie & PLZ --}} - @if($activeTab === 'geography') -
+ @if ($activeTab === 'geography') +
- {{-- Info-Box --}} - -
- -
-
- {{ __('Die Mapping-Engine') }} -
-
- {{ __('Hier ordnen Sie Postleitzahlen diesem Hub zu. Gibt ein Kunde seine PLZ ein, wird er automatisch diesem regionalen Marktplatz zugewiesen und sieht lokale Händler.') }} + {{-- Info-Box --}} + +
+ +
+
+ {{ __('Die Mapping-Engine') }} +
+
+ {{ __('Hier ordnen Sie Postleitzahlen diesem Hub zu. Gibt ein Kunde seine PLZ ein, wird er automatisch diesem regionalen Marktplatz zugewiesen und sieht lokale Händler.') }} +
-
- + - {{-- PLZ-Import Tools --}} - - {{ __('Postleitzahlen hinzufügen') }} + {{-- PLZ-Import Tools --}} + + {{ __('Postleitzahlen hinzufügen') }} - - {{ __('Einzeln') }} - {{ __('Bereich') }} - {{ __('CSV-Import') }} - + + {{ __('Einzeln') }} + {{ __('Bereich') }} + {{ __('CSV-Import') }} + -
- {{-- Einzelne PLZ --}} - @if($importMethod === 'single') -
-
- - {{ __('Postleitzahl') }} * - - - - {{ __('Stadt') }} * - - +
+ {{-- Einzelne PLZ --}} + @if ($importMethod === 'single') +
+
+ + {{ __('Postleitzahl') }} * + + + + + {{ __('Stadt') }} * + + +
+ + {{ __('PLZ hinzufügen') }} + +
+ @endif + + {{-- PLZ-Bereich --}} + @if ($importMethod === 'range') +
+
+ + {{ __('Von PLZ') }} * + + + + {{ __('Bis PLZ') }} * + + +
+ + {{ __('Bereich importieren') }} + + + ⚠️ + {{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }} + +
+ @endif + + {{-- CSV-Import --}} + @if ($importMethod === 'csv') +
+ + {{ __('CSV-Datei') }} * + + + {{ __('Format: PLZ,Stadt (eine Zeile pro Eintrag)') }} +
+ {{ __('Beispiel:') }} + + 33602,Bielefeld
33603,Bielefeld
33604,Bielefeld +
+
+
+ + {{ __('CSV importieren') }} + +
+ @endif +
+ + + {{-- PLZ-Liste --}} + +
+
+ + {{ __('Zugeordnete Postleitzahlen') }} + @if ($hubId) + + ({{ $this->locations->total() }} {{ __('gesamt') }}) + + @endif + +
- - {{ __('PLZ hinzufügen') }} - -
- @endif - {{-- PLZ-Bereich --}} - @if($importMethod === 'range') -
-
- - {{ __('Von PLZ') }} * - - - - {{ __('Bis PLZ') }} * - - -
- - {{ __('Bereich importieren') }} - - - ⚠️ {{ __('Generiert automatisch alle PLZs im Bereich. Kann bei großen Bereichen mehrere Minuten dauern!') }} - -
- @endif + @if ($hubId) + + + {{ __('PLZ') }} + {{ __('Stadt') }} + {{ __('Aktion') }} + - {{-- CSV-Import --}} - @if($importMethod === 'csv') -
- - {{ __('CSV-Datei') }} * - - - {{ __('Format: PLZ,Stadt (eine Zeile pro Eintrag)') }} -
- {{ __('Beispiel:') }} - - 33602,Bielefeld
33603,Bielefeld
33604,Bielefeld -
-
-
- - {{ __('CSV importieren') }} - -
- @endif -
- + + @forelse($this->locations as $location) + + + {{ $location->zip_code }} + + {{ $location->city_name }} + + + + + @empty + + + +

+ {{ __('Noch keine PLZs zugeordnet') }} +

+
+
+ @endforelse +
+ - {{-- PLZ-Liste --}} - -
-
- - {{ __('Zugeordnete Postleitzahlen') }} - @if($hubId) - - ({{ $this->locations->total() }} {{ __('gesamt') }}) - + {{-- Pagination --}} + @if ($this->locations->hasPages()) +
+ {{ $this->locations->links() }} +
@endif -
- + @else +
+ {{ __('Speichern Sie zuerst den Hub, um PLZs hinzuzufügen') }} +
+ @endif
- - @if($hubId) - - - {{ __('PLZ') }} - {{ __('Stadt') }} - {{ __('Aktion') }} - - - - @forelse($this->locations as $location) - - - {{ $location->zip_code }} - - {{ $location->city_name }} - - - - - @empty - - - -

- {{ __('Noch keine PLZs zugeordnet') }} -

-
-
- @endforelse -
-
- - {{-- Pagination --}} - @if($this->locations->hasPages()) -
- {{ $this->locations->links() }} -
- @endif - @else -
- {{ __('Speichern Sie zuerst den Hub, um PLZs hinzuzufügen') }} -
- @endif -
-
-
+ +
@endif {{-- TAB 3: Partner-Monitor --}} - @if($activeTab === 'partners') - - - {{ __('Partner in diesem Hub') }} - @if($hubId) - - ({{ $this->partners->count() }} {{ __('Partner') }}) - + @if ($activeTab === 'partners') + + + {{ __('Partner in diesem Hub') }} + @if ($hubId) + + ({{ $this->partners->count() }} {{ __('Partner') }}) + + @endif + + + {{-- Info --}} + +
+ {{ __('Logik:') }} +
    +
  • {{ __('Händler (Retailer) → Werden diesem Hub fest zugeordnet') }}
  • +
  • {{ __('Hersteller (Manufacturer) → Sind "Hub-agnostisch", in allen Hubs sichtbar') }}
  • +
  • {{ __('Makler (Estate-Agent) → Werden Hub zugeordnet für regionale Kundenakquise') }}
  • +
+
+
+ + @if ($hubId) + + + {{ __('Name') }} + {{ __('Typ') }} + {{ __('Stadt') }} + {{ __('Lieferradius') }} + {{ __('Status') }} + {{ __('Aktion') }} + + + + @forelse($this->partners as $partner) + + +
+ {{ $partner->company_name }} +
+ @if ($partner->display_name && $partner->display_name !== $partner->company_name) +
{{ $partner->display_name }}
+ @endif +
+ + @php + $typeColors = [ + 'Retailer' => 'blue', + 'Manufacturer' => 'purple', + 'Estate-Agent' => 'green', + ]; + @endphp + + {{ $partner->type }} + + + + {{ $partner->city ?? '-' }} + + + @if ($partner->delivery_radius_km) + {{ $partner->delivery_radius_km }} km + @else + - + @endif + + + + {{ $partner->is_active ? __('Aktiv') : __('Inaktiv') }} + + + + + {{ __('Details') }} + + +
+ @empty + + + +

+ {{ __('Noch keine Partner in diesem Hub') }} +

+

+ {{ __('Partner werden automatisch beim Onboarding zugeordnet (basierend auf PLZ)') }} +

+
+
+ @endforelse +
+
+ @else +
+ {{ __('Speichern Sie zuerst den Hub, um Partner-Zuordnungen zu sehen') }} +
@endif -
- - {{-- Info --}} - -
- {{ __('Logik:') }} -
    -
  • {{ __('Händler (Retailer) → Werden diesem Hub fest zugeordnet') }}
  • -
  • {{ __('Hersteller (Manufacturer) → Sind "Hub-agnostisch", in allen Hubs sichtbar') }}
  • -
  • {{ __('Makler (Estate-Agent) → Werden Hub zugeordnet für regionale Kundenakquise') }}
  • -
-
- - @if($hubId) - - - {{ __('Name') }} - {{ __('Typ') }} - {{ __('Stadt') }} - {{ __('Lieferradius') }} - {{ __('Status') }} - {{ __('Aktion') }} - - - - @forelse($this->partners as $partner) - - -
- {{ $partner->company_name }} -
- @if($partner->display_name && $partner->display_name !== $partner->company_name) -
{{ $partner->display_name }}
- @endif -
- - @php - $typeColors = [ - 'Retailer' => 'blue', - 'Manufacturer' => 'purple', - 'Estate-Agent' => 'green', - ]; - @endphp - - {{ $partner->type }} - - - - {{ $partner->city ?? '-' }} - - - @if($partner->delivery_radius_km) - {{ $partner->delivery_radius_km }} km - @else - - - @endif - - - - {{ $partner->is_active ? __('Aktiv') : __('Inaktiv') }} - - - - - {{ __('Details') }} - - -
- @empty - - - -

- {{ __('Noch keine Partner in diesem Hub') }} -

-

- {{ __('Partner werden automatisch beim Onboarding zugeordnet (basierend auf PLZ)') }} -

-
-
- @endforelse -
-
- @else -
- {{ __('Speichern Sie zuerst den Hub, um Partner-Zuordnungen zu sehen') }} -
- @endif -
@endif
- diff --git a/tests/Feature/DisplayListTest.php b/tests/Feature/DisplayListTest.php index 4dc5b30..c289592 100644 --- a/tests/Feature/DisplayListTest.php +++ b/tests/Feature/DisplayListTest.php @@ -197,7 +197,7 @@ test('can create a draft playlist from live modules', function () { test('can discard a draft playlist', function () { $user = User::factory()->create(); $version = DisplayVersion::factory()->create(); - $display = Display::factory()->create(); + $display = Display::factory()->create(['preview_token' => 'token-discard-123456789012345678901234']); createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]); Livewire::actingAs($user) @@ -205,6 +205,57 @@ test('can discard a draft playlist', function () { ->call('discardDraft', $display->id); expect($display->fresh()->draftPlaylist)->toBeNull(); + expect($display->fresh()->preview_token)->toBeNull(); +}); + +test('publishing a draft clears the preview token', function () { + $user = User::factory()->create(); + $liveVersion = DisplayVersion::factory()->create(); + $draftVersion = DisplayVersion::factory()->create(); + $display = Display::factory()->create(['preview_token' => 'token-publish-12345678901234567890123456']); + createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$liveVersion->id]); + createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$draftVersion->id]); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('publishDraft', $display->id); + + expect($display->fresh()->preview_token)->toBeNull(); +}); + +test('can rotate the preview token of a draft', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(); + $display = Display::factory()->create(['preview_token' => 'token-old-1234567890123456789012345678901']); + createDisplayListPlaylist($display, DisplayPlaylist::STATUS_DRAFT, [$version->id]); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('rotatePreviewToken', $display->id); + + $newToken = $display->fresh()->preview_token; + + expect($newToken)->not->toBeNull() + ->and($newToken)->not->toBe('token-old-1234567890123456789012345678901'); +}); + +test('can add multiple modules to a playlist at once', function () { + $user = User::factory()->create(); + $version1 = DisplayVersion::factory()->create(); + $version2 = DisplayVersion::factory()->create(); + $version3 = DisplayVersion::factory()->create(); + $display = Display::factory()->create(); + createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, []); + + Livewire::actingAs($user) + ->test(DisplayList::class) + ->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED) + ->set('versionsToAdd', [$version1->id, $version3->id]) + ->call('addSelectedVersions') + ->assertSet('selectedVersionIds', [$version1->id, $version3->id]) + ->assertSet('versionsToAdd', []); + + expect(true)->toBeTrue(); }); test('can publish a draft playlist over the live playlist', function () { @@ -313,8 +364,8 @@ test('module select only shows modules that can still be added', function () { Livewire::actingAs($user) ->test(DisplayList::class) ->call('openModal', $display->id, DisplayPlaylist::STATUS_PUBLISHED) - ->assertDontSeeHtml('