Skip to main content

Content & marketing

Map Card code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/map-card.blade.php

@props([
    'mapName' => 'chernarus',
    'mapSize' => 15360,
    'tileUrl' => null,
    'center' => [7680, 7680],
    'zoom' => 2,
    'minZoom' => 1,
    'maxZoom' => 7,
    'markers' => [],
    'paths' => [],
    'legend' => [],
    'height' => 'lg',
    'controls' => true,
    'fitMarkers' => false,
    'markerSidebar' => true,
    'layout' => 'overlay',
    'sidebarTitle' => 'Markers',
    'allowPlacement' => false,
    'placementLabel' => 'Waypoint',
    'heatmap' => [],
    'heatmapOptions' => [],
    'liveTracking' => null,
    'attribution' => '',
    'assetUrls' => [],
])

@php
    $heightClasses = [
        'sm' => 'h-72 sm:h-80',
        'base' => 'h-80 sm:h-96',
        'lg' => 'h-96 sm:h-[30rem]',
        'xl' => 'h-[28rem] sm:h-[36rem]',
    ];

    // Split layout: map panel keeps a mobile height, the row stretches on lg.
    $splitMapHeights = [
        'sm' => 'h-64 sm:h-72',
        'base' => 'h-72 sm:h-80',
        'lg' => 'h-80 sm:h-96',
        'xl' => 'h-96 sm:h-[28rem]',
    ];

    $splitRowHeights = [
        'sm' => 'lg:h-80',
        'base' => 'lg:h-96',
        'lg' => 'lg:h-[30rem]',
        'xl' => 'lg:h-[36rem]',
    ];

    $isSplit = $layout === 'split';

    $markerItems = collect($markers)->values();
    $pathItems = collect($paths)->values();
    $showMarkerSidebar = ! $isSplit && $markerSidebar && ($markerItems->isNotEmpty() || $allowPlacement);
    $showSplitSidebar = $isSplit && ($markerItems->isNotEmpty() || $allowPlacement || isset($sidebar) || isset($form));
    $sidebarId = 'map-marker-sidebar-'.Str::random(8);
    $mapLabel = trim((string) ($attributes->get('aria-label') ?: Str::headline((string) $mapName).' map'));

    $resolvedTileUrl = $tileUrl ?: "/maps/{$mapName}/tiles/{z}/{x}/{y}.webp";
    $resolvedAssetUrls = array_replace([
        'css' => 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
        'js' => 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
    ], $assetUrls);

    $config = [
        'mapName' => $mapName,
        'mapSize' => (int) $mapSize,
        'tileUrl' => $resolvedTileUrl,
        'center' => $center,
        'zoom' => (int) $zoom,
        'minZoom' => (int) $minZoom,
        'maxZoom' => (int) $maxZoom,
        'controls' => (bool) $controls,
        'fitMarkers' => (bool) $fitMarkers,
        'attribution' => $attribution,
        'markers' => $markerItems->all(),
        'paths' => $pathItems->all(),
        'allowPlacement' => (bool) $allowPlacement,
        'placementLabel' => $placementLabel,
        'heatmap' => collect($heatmap)->values()->all(),
        'heatmapOptions' => $heatmapOptions,
        'liveTracking' => $liveTracking,
        'assetUrls' => $resolvedAssetUrls,
    ];
@endphp

<div
    data-leaflet-map-card
    data-map-config='@json($config)'
    {{ $attributes->except('aria-label')->class([
        'harbour-map-card overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900',
        'flex flex-col lg:flex-row '.($splitRowHeights[$height] ?? $splitRowHeights['lg']) => $isSplit,
    ]) }}
>
    <div
        @class([
            'relative bg-gray-950',
            $heightClasses[$height] ?? $heightClasses['lg'] => ! $isSplit,
            ($splitMapHeights[$height] ?? $splitMapHeights['lg']).' lg:h-auto lg:w-2/3' => $isSplit,
        ])
        @if ($showMarkerSidebar || $allowPlacement)
            x-data="{ sidebarOpen: false, placing: @js($allowPlacement) }"
            @keydown.escape.window="sidebarOpen = false"
            :class="{ 'harbour-map-card--placing': placing }"
        @endif
    >
        <div
            data-map-target
            role="application"
            aria-label="{{ $mapLabel }}"
            @class([
                'absolute inset-0 z-0',
                'harbour-map-card-target' => $allowPlacement,
            ])
        ></div>

        @if ($allowPlacement)
            <div
                data-map-placement-toolbar
                class="absolute start-3 top-3 z-20 flex flex-wrap items-center gap-2"
            >
                <button
                    type="button"
                    data-map-placement-toggle
                    class="inline-flex items-center gap-2 rounded-xl border border-brand-300 bg-brand-600 px-3 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-500 dark:border-brand-700"
                    x-on:click="placing = ! placing"
                    :aria-pressed="placing"
                >
                    <x-ui.icon name="map-pin" size="sm" />
                    <span x-text="placing ? 'Placing waypoints' : 'Place waypoints'">Place waypoints</span>
                </button>

                @if ($showMarkerSidebar)
                    <button
                        type="button"
                        data-map-marker-toggle
                        x-show="! sidebarOpen"
                        x-cloak
                        class="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white/95 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-gray-900/5 transition hover:border-brand-300 hover:text-brand-700 dark:border-gray-800 dark:bg-gray-900/95 dark:text-gray-100 dark:ring-white/5 dark:hover:border-brand-800 dark:hover:text-brand-300"
                        x-on:click="sidebarOpen = true"
                        :aria-expanded="sidebarOpen"
                        aria-controls="{{ $sidebarId }}"
                    >
                        <x-ui.icon name="list" size="sm" />
                        <span>Markers</span>
                        <x-ui.badge variant="brand" size="sm" data-map-marker-count>0</x-ui.badge>
                    </button>
                @endif

                <button
                    type="button"
                    data-map-clear-markers
                    class="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white/95 px-3 py-2 text-sm font-semibold text-gray-700 shadow-sm ring-1 ring-gray-900/5 transition hover:border-red-300 hover:text-red-700 dark:border-gray-800 dark:bg-gray-900/95 dark:text-gray-200 dark:ring-white/5 dark:hover:border-red-800 dark:hover:text-red-300"
                >
                    <x-ui.icon name="trash" size="sm" />
                    Clear
                </button>
            </div>

            <p
                data-map-placement-hint
                x-show="placing"
                x-cloak
                class="pointer-events-none absolute inset-x-0 bottom-16 z-20 mx-auto w-fit rounded-full border border-white/10 bg-gray-950/80 px-4 py-2 text-xs font-semibold text-white shadow-lg backdrop-blur"
            >
                Click the map to drop {{ Str::lower($placementLabel) }}s
            </p>
        @endif

        @if ($showMarkerSidebar)
            <div
                x-show="sidebarOpen"
                x-cloak
                x-transition.opacity
                class="absolute inset-0 z-10 bg-gray-950/45 backdrop-blur-[1px]"
                x-on:click="sidebarOpen = false"
            ></div>

            <aside
                data-map-marker-sidebar
                x-show="sidebarOpen"
                x-cloak
                x-transition:enter="transform transition ease-out duration-200"
                x-transition:enter-start="-translate-x-full"
                x-transition:enter-end="translate-x-0"
                x-transition:leave="transform transition ease-in duration-150"
                x-transition:leave-start="translate-x-0"
                x-transition:leave-end="-translate-x-full"
                class="absolute inset-y-0 start-0 z-20 flex w-72 max-w-[85%] flex-col border-e border-gray-200 bg-white/96 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/96"
                aria-label="Map markers"
                id="{{ $sidebarId }}"
            >
                <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-800">
                    <div class="min-w-0">
                        <p class="text-sm font-semibold text-gray-900 dark:text-white">Markers</p>
                        <p class="text-xs text-gray-500 dark:text-gray-400" data-map-marker-summary>
                            @if ($allowPlacement)
                                0 locations
                            @else
                                {{ $markerItems->count() }} locations
                            @endif
                            @if ($pathItems->isNotEmpty())
                                · {{ $pathItems->count() }} {{ Str::plural('path', $pathItems->count()) }}
                            @endif
                        </p>
                    </div>

                    <x-ui.icon-button
                        icon="x"
                        label="Close marker list"
                        size="sm"
                        variant="ghost"
                        x-on:click="sidebarOpen = false"
                    />
                </div>

                <div class="min-h-0 flex-1 overflow-y-auto p-2">
                    <x-ui.map-marker-list
                        :markers="$markerItems->all()"
                        :paths="$pathItems->all()"
                        :allow-placement="$allowPlacement"
                        :placement-label="$placementLabel"
                    />
                </div>
            </aside>

            @if (! $allowPlacement)
            <button
                type="button"
                data-map-marker-toggle
                x-show="! sidebarOpen"
                x-cloak
                class="absolute start-3 top-3 z-20 inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white/95 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-gray-900/5 transition hover:border-brand-300 hover:text-brand-700 dark:border-gray-800 dark:bg-gray-900/95 dark:text-gray-100 dark:ring-white/5 dark:hover:border-brand-800 dark:hover:text-brand-300"
                x-on:click="sidebarOpen = true"
                :aria-expanded="sidebarOpen"
                aria-controls="{{ $sidebarId }}"
            >
                <x-ui.icon name="list" size="sm" />
                <span>Markers</span>
                <x-ui.badge variant="brand" size="sm">{{ $markerItems->count() }}</x-ui.badge>
            </button>
            @endif
        @endif

        <div data-map-loading role="status" aria-live="polite" class="absolute inset-0 z-10 flex items-center justify-center bg-gray-950 text-sm font-medium text-gray-300">
            <span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1.5">
                <x-ui.spinner size="sm" />
                Loading map
            </span>
        </div>

        <div data-map-error role="alert" class="absolute inset-0 z-10 hidden items-center justify-center bg-gray-950 p-6 text-center">
            <div class="max-w-sm">
                <div class="mx-auto flex h-10 w-10 items-center justify-center rounded-xl bg-red-500/10 text-red-300 ring-1 ring-red-500/20">
                    <x-ui.icon name="warning" size="xl" />
                </div>
                <p class="mt-3 text-sm font-semibold text-white">Map unavailable</p>
                <p class="mt-1 text-sm text-gray-400">Leaflet or the configured tile layer could not load.</p>
            </div>
        </div>
    </div>

    @if ($showSplitSidebar)
        <aside
            class="flex min-h-0 w-full flex-col border-t border-gray-200 bg-white lg:w-1/3 lg:border-s lg:border-t-0 dark:border-gray-800 dark:bg-gray-900"
            aria-label="{{ $sidebarTitle }}"
            id="{{ $sidebarId }}"
        >
            <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-800">
                <div class="min-w-0">
                    <p class="text-sm font-semibold text-gray-900 dark:text-white">{{ $sidebarTitle }}</p>
                    <p class="text-xs text-gray-500 dark:text-gray-400" data-map-marker-summary>
                        @if ($allowPlacement)
                            0 locations
                        @else
                            {{ $markerItems->count() }} {{ Str::plural('location', $markerItems->count()) }}
                        @endif
                        @if ($pathItems->isNotEmpty())
                            · {{ $pathItems->count() }} {{ Str::plural('path', $pathItems->count()) }}
                        @endif
                    </p>
                </div>

                @unless ($allowPlacement)
                    <x-ui.badge variant="brand" size="sm" data-map-location-count>{{ $markerItems->count() }}</x-ui.badge>
                @endunless
            </div>

            @isset($form)
                <div class="border-b border-gray-200 p-4 dark:border-gray-800">
                    {{ $form }}
                </div>
            @endisset

            @if ($markerItems->isNotEmpty() || $allowPlacement || isset($sidebar))
                <div class="min-h-0 flex-1 overflow-y-auto p-2">
                    @isset($sidebar)
                        {{ $sidebar }}
                    @else
                        <x-ui.map-marker-list
                            :markers="$markerItems->all()"
                            :paths="$pathItems->all()"
                            :allow-placement="$allowPlacement"
                            :placement-label="$placementLabel"
                        />
                    @endisset
                </div>
            @endif
        </aside>
    @endif
</div>