Compare commits
2 Commits
07399d7d45
...
25515eccb9
| Author | SHA1 | Date | |
|---|---|---|---|
| 25515eccb9 | |||
| 5563826a08 |
@ -103,11 +103,24 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$this->authorize('view', $event);
|
$this->authorize('view', $event);
|
||||||
|
|
||||||
$event->load(['meta', 'meta.venue']);
|
$event->loadMissing(['meta', 'meta.venue']);
|
||||||
|
|
||||||
$isHtmx = $request->header('HX-Request') === 'true';
|
$isHtmx = $request->header('HX-Request') === 'true';
|
||||||
$tz = $this->displayTimezone($calendar, $request);
|
$tz = $this->displayTimezone($calendar, $request);
|
||||||
|
|
||||||
|
$instance = $calendar->instanceForUser($request->user());
|
||||||
|
if ($instance) {
|
||||||
|
$instance->loadMissing('meta');
|
||||||
|
}
|
||||||
|
|
||||||
|
$calendarColor = calendar_color(
|
||||||
|
['color' => $instance?->meta?->color],
|
||||||
|
$instance?->calendarcolor
|
||||||
|
);
|
||||||
|
$calendarColorFg = $instance?->meta?->color_fg
|
||||||
|
?? contrast_text_color($calendarColor);
|
||||||
|
$calendarName = $instance?->displayname ?? __('common.calendar');
|
||||||
|
|
||||||
// prefer occurrence when supplied (recurring events), fall back to meta, then sabre columns
|
// prefer occurrence when supplied (recurring events), fall back to meta, then sabre columns
|
||||||
$occurrenceParam = $request->query('occurrence');
|
$occurrenceParam = $request->query('occurrence');
|
||||||
$occurrenceStart = null;
|
$occurrenceStart = null;
|
||||||
@ -136,7 +149,12 @@ class EventController extends Controller
|
|||||||
$start = $startUtc->copy()->timezone($tz);
|
$start = $startUtc->copy()->timezone($tz);
|
||||||
$end = $endUtc->copy()->timezone($tz);
|
$end = $endUtc->copy()->timezone($tz);
|
||||||
|
|
||||||
$data = compact('calendar', 'event', 'start', 'end', 'tz');
|
$map = $this->buildBasemapTiles($event->meta?->venue);
|
||||||
|
|
||||||
|
$event->setAttribute('color', $calendarColor);
|
||||||
|
$event->setAttribute('color_fg', $calendarColorFg);
|
||||||
|
|
||||||
|
$data = compact('calendar', 'event', 'start', 'end', 'tz', 'map', 'calendarName');
|
||||||
|
|
||||||
return $isHtmx
|
return $isHtmx
|
||||||
? view('event.partials.details', $data)
|
? view('event.partials.details', $data)
|
||||||
@ -412,4 +430,53 @@ class EventController extends Controller
|
|||||||
|
|
||||||
return Location::labelOnly($raw)->id;
|
return Location::labelOnly($raw)->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* build static basemap tiles for an event location
|
||||||
|
*/
|
||||||
|
private function buildBasemapTiles(?Location $venue): array
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'enabled' => false,
|
||||||
|
'needs_key' => false,
|
||||||
|
'url' => null,
|
||||||
|
'zoom' => (int) config('services.geocoding.arcgis.basemap_zoom', 14),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$venue || !is_numeric($venue->lat) || !is_numeric($venue->lon)) {
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = config('services.geocoding.arcgis.api_key');
|
||||||
|
if (!$token) {
|
||||||
|
$map['needs_key'] = true;
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
$style = config('services.geocoding.arcgis.basemap_style', 'arcgis/light-gray');
|
||||||
|
$zoom = max(0, (int) $map['zoom']);
|
||||||
|
|
||||||
|
$lat = max(min((float) $venue->lat, 85.05112878), -85.05112878);
|
||||||
|
$lon = (float) $venue->lon;
|
||||||
|
$n = 2 ** $zoom;
|
||||||
|
|
||||||
|
$x = (int) floor(($lon + 180.0) / 360.0 * $n);
|
||||||
|
$latRad = deg2rad($lat);
|
||||||
|
$y = (int) floor((1.0 - log(tan($latRad) + (1 / cos($latRad))) / M_PI) / 2.0 * $n);
|
||||||
|
|
||||||
|
$base = 'https://static-map-tiles-api.arcgis.com/arcgis/rest/services/static-basemap-tiles-service/v1';
|
||||||
|
|
||||||
|
$tx = $x % $n;
|
||||||
|
if ($tx < 0) {
|
||||||
|
$tx += $n;
|
||||||
|
}
|
||||||
|
$ty = max(0, min($n - 1, $y));
|
||||||
|
|
||||||
|
$map['url'] = "{$base}/{$style}/static/tile/{$zoom}/{$ty}/{$tx}?token={$token}";
|
||||||
|
|
||||||
|
$map['enabled'] = true;
|
||||||
|
$map['zoom'] = $zoom;
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Services\Location\Geocoder;
|
use App\Services\Location\Geocoder;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class LocationController extends Controller
|
class LocationController extends Controller
|
||||||
{
|
{
|
||||||
|
|||||||
@ -69,13 +69,31 @@ class GeocodeEventLocations implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// geocode the free-form location string
|
// geocode the free-form location string; if it looks like a label,
|
||||||
$norm = $geocoder->forward($meta->location);
|
// fall back to the normalized location record's raw address
|
||||||
|
$query = $meta->location;
|
||||||
|
$norm = $geocoder->forward($query);
|
||||||
|
|
||||||
|
if (!$norm) {
|
||||||
|
$location = Location::where('display_name', $meta->location)
|
||||||
|
->orWhere('raw_address', $meta->location)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($location?->raw_address) {
|
||||||
|
$query = $location->raw_address;
|
||||||
|
$norm = $geocoder->forward($query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// skip obvious non-address labels or unresolved queries
|
// skip obvious non-address labels or unresolved queries
|
||||||
if (!$norm || (!$norm['lat'] && !$norm['street'])) {
|
if (!$norm || (!$norm['lat'] && !$norm['street'])) {
|
||||||
$skipped++;
|
$skipped++;
|
||||||
$processed++;
|
$processed++;
|
||||||
|
Log::info('GeocodeEventLocations: skipped', [
|
||||||
|
'event_id' => $meta->event_id,
|
||||||
|
'location' => $meta->location,
|
||||||
|
'query' => $query,
|
||||||
|
]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -154,9 +154,11 @@ class CalendarViewBuilder
|
|||||||
})->filter();
|
})->filter();
|
||||||
|
|
||||||
// ensure chronological ordering across calendars for all views
|
// ensure chronological ordering across calendars for all views
|
||||||
return $payloads
|
$payloads = $payloads
|
||||||
->sortBy('start')
|
->sortBy('start')
|
||||||
->keyBy('occurrence_id');
|
->keyBy('occurrence_id');
|
||||||
|
|
||||||
|
return $this->applyOverlapLayout($payloads, $view);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -457,4 +459,105 @@ class CalendarViewBuilder
|
|||||||
$rounded = (int) round($minutes / $slot) * $slot;
|
$rounded = (int) round($minutes / $slot) * $slot;
|
||||||
return max($min, min($rounded, $max));
|
return max($min, min($rounded, $max));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply overlap metadata for time-based views (day/four/week).
|
||||||
|
*/
|
||||||
|
private function applyOverlapLayout(Collection $events, string $view): Collection
|
||||||
|
{
|
||||||
|
if (!in_array($view, ['day', 'four', 'week'], true) || $events->isEmpty()) {
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $events->all(); // keyed by occurrence_id
|
||||||
|
$eventsByCol = [];
|
||||||
|
|
||||||
|
foreach ($items as $id => $event) {
|
||||||
|
$col = (int) ($event['start_col'] ?? 1);
|
||||||
|
$eventsByCol[$col][$id] = $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($eventsByCol as $group) {
|
||||||
|
uasort($group, function (array $a, array $b) {
|
||||||
|
$cmp = ($a['start_row'] ?? 0) <=> ($b['start_row'] ?? 0);
|
||||||
|
if ($cmp !== 0) {
|
||||||
|
return $cmp;
|
||||||
|
}
|
||||||
|
return ($a['end_row'] ?? 0) <=> ($b['end_row'] ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
$cluster = [];
|
||||||
|
$clusterEnd = null;
|
||||||
|
|
||||||
|
foreach ($group as $id => $event) {
|
||||||
|
$startRow = (int) ($event['start_row'] ?? 0);
|
||||||
|
$endRow = (int) ($event['end_row'] ?? 0);
|
||||||
|
|
||||||
|
if ($clusterEnd !== null && $startRow >= $clusterEnd) {
|
||||||
|
$this->assignOverlapCluster($cluster, $items);
|
||||||
|
$cluster = [];
|
||||||
|
$clusterEnd = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cluster[$id] = $event;
|
||||||
|
$clusterEnd = $clusterEnd === null
|
||||||
|
? $endRow
|
||||||
|
: max($clusterEnd, $endRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cluster) {
|
||||||
|
$this->assignOverlapCluster($cluster, $items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assignOverlapCluster(array $cluster, array &$items): void
|
||||||
|
{
|
||||||
|
if (empty($cluster)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$active = [];
|
||||||
|
$availableCols = [];
|
||||||
|
$assigned = [];
|
||||||
|
$maxCol = 0;
|
||||||
|
|
||||||
|
foreach ($cluster as $id => $event) {
|
||||||
|
$startRow = (int) ($event['start_row'] ?? 0);
|
||||||
|
$endRow = (int) ($event['end_row'] ?? 0);
|
||||||
|
|
||||||
|
foreach ($active as $idx => $info) {
|
||||||
|
if ($info['end'] <= $startRow) {
|
||||||
|
$availableCols[] = $info['col'];
|
||||||
|
unset($active[$idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($availableCols);
|
||||||
|
|
||||||
|
if (!empty($availableCols)) {
|
||||||
|
$col = array_shift($availableCols);
|
||||||
|
} else {
|
||||||
|
$col = $maxCol;
|
||||||
|
$maxCol++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assigned[$id] = $col;
|
||||||
|
$active[] = ['end' => $endRow, 'col' => $col];
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalCols = max(1, $maxCol);
|
||||||
|
$width = 100 / $totalCols;
|
||||||
|
|
||||||
|
foreach ($cluster as $id => $event) {
|
||||||
|
$index = $assigned[$id] ?? 0;
|
||||||
|
$items[$id]['overlap_count'] = $totalCols;
|
||||||
|
$items[$id]['overlap_index'] = $index;
|
||||||
|
$items[$id]['overlap_width'] = round($width, 4);
|
||||||
|
$items[$id]['overlap_offset'] = round($width * $index, 4);
|
||||||
|
$items[$id]['overlap_z'] = $index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,11 +4,14 @@ namespace App\Services\Location;
|
|||||||
|
|
||||||
use \App\Models\User;
|
use \App\Models\User;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class Geocoder
|
class Geocoder
|
||||||
{
|
{
|
||||||
|
private bool $missingKeyWarned = false;
|
||||||
|
|
||||||
public function __construct(private array $cfg = [])
|
public function __construct(private array $cfg = [])
|
||||||
{
|
{
|
||||||
$this->cfg = config("services.geocoding");
|
$this->cfg = config("services.geocoding");
|
||||||
@ -65,6 +68,54 @@ class Geocoder
|
|||||||
return rtrim($this->cfg["arcgis"]["endpoint"], "/");
|
return rtrim($this->cfg["arcgis"]["endpoint"], "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch arcgis api key (log once if missing)
|
||||||
|
*/
|
||||||
|
private function arcgisKey(): ?string
|
||||||
|
{
|
||||||
|
$key = $this->cfg['arcgis']['api_key'] ?? null;
|
||||||
|
|
||||||
|
if (!$key) {
|
||||||
|
$this->warnMissingKey();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function warnMissingKey(): void
|
||||||
|
{
|
||||||
|
if ($this->missingKeyWarned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->missingKeyWarned = true;
|
||||||
|
Log::warning('arcgis api key missing; geocoding disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function arcgisDebugEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->cfg['arcgis']['debug'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logArcgisResponse(string $label, array $params, $res): void
|
||||||
|
{
|
||||||
|
if (! $this->arcgisDebugEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeParams = $params;
|
||||||
|
if (isset($safeParams['token'])) {
|
||||||
|
$safeParams['token'] = '***';
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info("arcgis {$label} response", [
|
||||||
|
'status' => $res->status(),
|
||||||
|
'params' => $safeParams,
|
||||||
|
'body' => $res->body(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* pull a bias from the user (zip -> centroid) and cache it
|
* pull a bias from the user (zip -> centroid) and cache it
|
||||||
*/
|
*/
|
||||||
@ -85,8 +136,13 @@ class Geocoder
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$key = $this->arcgisKey();
|
||||||
|
if (!$key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$cacheKey = "geo:bias:zip:{$zip}";
|
$cacheKey = "geo:bias:zip:{$zip}";
|
||||||
$bias = Cache::remember($cacheKey, now()->addDays(7), function () use ($zip) {
|
$bias = Cache::remember($cacheKey, now()->addDays(7), function () use ($zip, $key) {
|
||||||
$a = $this->cfg['arcgis'];
|
$a = $this->cfg['arcgis'];
|
||||||
|
|
||||||
$params = [
|
$params = [
|
||||||
@ -94,11 +150,12 @@ class Geocoder
|
|||||||
'category' => 'Postal',
|
'category' => 'Postal',
|
||||||
'maxLocations' => 1,
|
'maxLocations' => 1,
|
||||||
'f' => 'pjson',
|
'f' => 'pjson',
|
||||||
'token' => $a['api_key'],
|
'token' => $key,
|
||||||
'countryCode' => $a['country_code'] ?? null,
|
'countryCode' => $a['country_code'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
||||||
|
$this->logArcgisResponse('zip-bias', $params, $res);
|
||||||
if (!$res->ok()) {
|
if (!$res->ok()) {
|
||||||
Log::info('arcgis zip bias lookup failed', ['zip' => $zip, 'status' => $res->status()]);
|
Log::info('arcgis zip bias lookup failed', ['zip' => $zip, 'status' => $res->status()]);
|
||||||
return null;
|
return null;
|
||||||
@ -139,17 +196,25 @@ class Geocoder
|
|||||||
private function forwardArcgis(string $query, ?array $bias): ?array
|
private function forwardArcgis(string $query, ?array $bias): ?array
|
||||||
{
|
{
|
||||||
$a = $this->cfg['arcgis'];
|
$a = $this->cfg['arcgis'];
|
||||||
|
$key = $this->arcgisKey();
|
||||||
|
if (!$key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$params = [
|
$params = [
|
||||||
'singleLine' => $query,
|
'singleLine' => $query,
|
||||||
'outFields' => $a['out_fields'] ?? '*',
|
'outFields' => $a['out_fields'] ?? '*',
|
||||||
'maxLocations' => (int)($a['max_results'] ?? 5),
|
'maxLocations' => (int)($a['max_results'] ?? 5),
|
||||||
'f' => 'pjson',
|
'f' => 'pjson',
|
||||||
'token' => $a['api_key'],
|
'token' => $key,
|
||||||
'category' => $a['categories'] ?? 'POI,Address',
|
'category' => $a['categories'] ?? 'POI,Address',
|
||||||
'countryCode' => $a['country_code'] ?? null,
|
'countryCode' => $a['country_code'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (!empty($a['store'])) {
|
||||||
|
$params['forStorage'] = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
if ($bias && $bias['lat'] && $bias['lon']) {
|
if ($bias && $bias['lat'] && $bias['lon']) {
|
||||||
$params['location'] = $bias['lon'].','.$bias['lat'];
|
$params['location'] = $bias['lon'].','.$bias['lat'];
|
||||||
if (!empty($bias['radius_km'])) {
|
if (!empty($bias['radius_km'])) {
|
||||||
@ -158,6 +223,7 @@ class Geocoder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
||||||
|
$this->logArcgisResponse('forward', $params, $res);
|
||||||
if (!$res->ok()) {
|
if (!$res->ok()) {
|
||||||
Log::warning('arcgis forward geocode failed', ['status' => $res->status(), 'q' => $query]);
|
Log::warning('arcgis forward geocode failed', ['status' => $res->status(), 'q' => $query]);
|
||||||
return null;
|
return null;
|
||||||
@ -165,6 +231,9 @@ class Geocoder
|
|||||||
|
|
||||||
$cands = $res->json('candidates', []);
|
$cands = $res->json('candidates', []);
|
||||||
if (!$cands) {
|
if (!$cands) {
|
||||||
|
if ($this->arcgisDebugEnabled()) {
|
||||||
|
Log::info('arcgis forward geocode empty', ['q' => $query]);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,13 +250,23 @@ class Geocoder
|
|||||||
private function reverseArcgis(float $lat, float $lon): ?array
|
private function reverseArcgis(float $lat, float $lon): ?array
|
||||||
{
|
{
|
||||||
$a = $this->cfg['arcgis'];
|
$a = $this->cfg['arcgis'];
|
||||||
|
$key = $this->arcgisKey();
|
||||||
|
if (!$key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$params = [
|
$params = [
|
||||||
'location' => "{$lon},{$lat}",
|
'location' => "{$lon},{$lat}",
|
||||||
'f' => 'pjson',
|
'f' => 'pjson',
|
||||||
'token' => $a['api_key'],
|
'token' => $key,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (!empty($a['store'])) {
|
||||||
|
$params['forStorage'] = 'true';
|
||||||
|
}
|
||||||
|
|
||||||
$res = $this->http()->get($this->arcgisBase().'/reverseGeocode', $params);
|
$res = $this->http()->get($this->arcgisBase().'/reverseGeocode', $params);
|
||||||
|
$this->logArcgisResponse('reverse', $params, $res);
|
||||||
if (!$res->ok()) {
|
if (!$res->ok()) {
|
||||||
Log::warning('arcgis reverse geocode failed', ['status' => $res->status()]);
|
Log::warning('arcgis reverse geocode failed', ['status' => $res->status()]);
|
||||||
return null;
|
return null;
|
||||||
@ -247,8 +326,10 @@ class Geocoder
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$bias = $this->biasForUser($user);
|
||||||
|
|
||||||
return match ($provider) {
|
return match ($provider) {
|
||||||
"arcgis" => $this->arcgisSuggestions($query, $limit),
|
"arcgis" => $this->arcgisSuggestions($query, $limit, $bias),
|
||||||
default => [],
|
default => [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -256,17 +337,34 @@ class Geocoder
|
|||||||
/**
|
/**
|
||||||
* get the suggestions from arcgis
|
* get the suggestions from arcgis
|
||||||
*/
|
*/
|
||||||
private function arcgisSuggestions(string $query, int $limit): array
|
private function arcgisSuggestions(string $query, int $limit, ?array $bias = null): array
|
||||||
{
|
{
|
||||||
|
$key = $this->arcgisKey();
|
||||||
|
if (!$key) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$params = [
|
$params = [
|
||||||
"singleLine" => $query,
|
"singleLine" => $query,
|
||||||
"outFields" => $this->cfg["arcgis"]["out_fields"] ?? "*",
|
"outFields" => $this->cfg["arcgis"]["out_fields"] ?? "*",
|
||||||
"maxLocations" => $limit,
|
"maxLocations" => $limit,
|
||||||
// you can bias results with 'countryCode' or 'location' here if desired
|
"category" => $this->cfg["arcgis"]["categories"] ?? "POI,Address",
|
||||||
|
"countryCode" => $this->cfg["arcgis"]["country_code"] ?? null,
|
||||||
"f" => "pjson",
|
"f" => "pjson",
|
||||||
"token" => $this->cfg["arcgis"]["api_key"],
|
"token" => $key,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if ($bias && $bias['lat'] && $bias['lon']) {
|
||||||
|
$params['location'] = $bias['lon'] . ',' . $bias['lat'];
|
||||||
|
if (!empty($bias['radius_km'])) {
|
||||||
|
$params['searchExtent'] = $this->bboxFromBias(
|
||||||
|
(float) $bias['lat'],
|
||||||
|
(float) $bias['lon'],
|
||||||
|
(float) $bias['radius_km']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!empty($this->cfg["arcgis"]["store"])) {
|
if (!empty($this->cfg["arcgis"]["store"])) {
|
||||||
$params["forStorage"] = "true";
|
$params["forStorage"] = "true";
|
||||||
}
|
}
|
||||||
@ -275,6 +373,7 @@ class Geocoder
|
|||||||
$this->arcgisBase() . "/findAddressCandidates",
|
$this->arcgisBase() . "/findAddressCandidates",
|
||||||
$params,
|
$params,
|
||||||
);
|
);
|
||||||
|
$this->logArcgisResponse('suggestions', $params, $res);
|
||||||
if (!$res->ok()) {
|
if (!$res->ok()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\HtmxAwareAuthenticate;
|
||||||
use App\Http\Middleware\SetUserLocale;
|
use App\Http\Middleware\SetUserLocale;
|
||||||
use App\Jobs\GeocodeEventLocations;
|
use App\Jobs\GeocodeEventLocations;
|
||||||
use App\Jobs\SyncSubscriptionsDispatcher;
|
use App\Jobs\SyncSubscriptionsDispatcher;
|
||||||
@ -21,6 +22,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
SetUserLocale::class,
|
SetUserLocale::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ensure HTMX requests redirect in the top-level window on auth expiry
|
||||||
|
$middleware->alias([
|
||||||
|
'auth' => HtmxAwareAuthenticate::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
|
|
||||||
->withSchedule(function (Schedule $schedule) {
|
->withSchedule(function (Schedule $schedule) {
|
||||||
|
|||||||
@ -42,6 +42,7 @@ return [
|
|||||||
"arcgis" => [
|
"arcgis" => [
|
||||||
"api_key" => env("ARCGIS_API_KEY"),
|
"api_key" => env("ARCGIS_API_KEY"),
|
||||||
"store" => (bool) env("ARCGIS_STORE_RESULTS", true),
|
"store" => (bool) env("ARCGIS_STORE_RESULTS", true),
|
||||||
|
"debug" => (bool) env("ARCGIS_DEBUG", false),
|
||||||
"endpoint" =>
|
"endpoint" =>
|
||||||
"https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer",
|
"https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer",
|
||||||
"country_code" => env("GEOCODER_COUNTRY", "USA"),
|
"country_code" => env("GEOCODER_COUNTRY", "USA"),
|
||||||
@ -49,6 +50,8 @@ return [
|
|||||||
"out_fields" =>
|
"out_fields" =>
|
||||||
"Match_addr,Addr_type,PlaceName,Place_addr,Address,City,Region,Postal,CountryCode,LongLabel",
|
"Match_addr,Addr_type,PlaceName,Place_addr,Address,City,Region,Postal,CountryCode,LongLabel",
|
||||||
"max_results" => 1,
|
"max_results" => 1,
|
||||||
|
"basemap_style" => env("ARCGIS_BASEMAP_STYLE", "arcgis/community"),
|
||||||
|
"basemap_zoom" => (int) env("ARCGIS_BASEMAP_ZOOM", 16),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -307,6 +307,13 @@ ICS;
|
|||||||
$insertEvent($future5a, 'Teacher conference (3rd grade)', 'Fairview Elementary');
|
$insertEvent($future5a, 'Teacher conference (3rd grade)', 'Fairview Elementary');
|
||||||
$insertEvent($future5b, 'Family game night', 'Living Room');
|
$insertEvent($future5b, 'Family game night', 'Living Room');
|
||||||
|
|
||||||
|
// overlapping events 3 days after "now"
|
||||||
|
$overlapDay = $now->copy()->addDays(3)->setTime(9, 0);
|
||||||
|
$insertEvent($overlapDay->copy(), 'Overlap: Daily standup', 'Home');
|
||||||
|
$insertEvent($overlapDay->copy()->addMinutes(15), 'Overlap: Design review', 'Living Room');
|
||||||
|
$insertEvent($overlapDay->copy()->addMinutes(30), 'Overlap: Vendor call', 'McCahill Park');
|
||||||
|
$insertEvent($overlapDay->copy()->addMinutes(45), 'Overlap: Planning session', 'Meadow Park');
|
||||||
|
|
||||||
// recurring: weekly on Mon/Wed for 8 weeks at 6:30pm
|
// recurring: weekly on Mon/Wed for 8 weeks at 6:30pm
|
||||||
$recurringStart = $now->copy()->next(Carbon::MONDAY)->setTime(18, 30);
|
$recurringStart = $now->copy()->next(Carbon::MONDAY)->setTime(18, 30);
|
||||||
$insertRecurringEvent(
|
$insertRecurringEvent(
|
||||||
|
|||||||
@ -53,6 +53,8 @@ return [
|
|||||||
'all_day' => 'All day',
|
'all_day' => 'All day',
|
||||||
'location' => 'Location',
|
'location' => 'Location',
|
||||||
'map_coming' => 'Map preview coming soon.',
|
'map_coming' => 'Map preview coming soon.',
|
||||||
|
'map_needs_key' => 'Map preview requires an ArcGIS basemap API key.',
|
||||||
|
'map_attribution' => 'Basemap tiles © Esri and the GIS User Community.',
|
||||||
'no_location' => 'No location set.',
|
'no_location' => 'No location set.',
|
||||||
'details' => 'Details',
|
'details' => 'Details',
|
||||||
'repeats' => 'Repeats',
|
'repeats' => 'Repeats',
|
||||||
|
|||||||
@ -53,6 +53,8 @@ return [
|
|||||||
'all_day' => 'Tutto il giorno',
|
'all_day' => 'Tutto il giorno',
|
||||||
'location' => 'Luogo',
|
'location' => 'Luogo',
|
||||||
'map_coming' => 'Anteprima mappa in arrivo.',
|
'map_coming' => 'Anteprima mappa in arrivo.',
|
||||||
|
'map_needs_key' => 'Anteprima mappa richiede una chiave API ArcGIS.',
|
||||||
|
'map_attribution' => 'Tessere mappa © Esri e la comunita GIS.',
|
||||||
'no_location' => 'Nessun luogo impostato.',
|
'no_location' => 'Nessun luogo impostato.',
|
||||||
'details' => 'Dettagli',
|
'details' => 'Dettagli',
|
||||||
'repeats' => 'Ripete',
|
'repeats' => 'Ripete',
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
@import './lib/calendar.css';
|
@import './lib/calendar.css';
|
||||||
@import './lib/checkbox.css';
|
@import './lib/checkbox.css';
|
||||||
@import './lib/color.css';
|
@import './lib/color.css';
|
||||||
|
@import './lib/event.css';
|
||||||
@import './lib/icon.css';
|
@import './lib/icon.css';
|
||||||
@import './lib/indicator.css';
|
@import './lib/indicator.css';
|
||||||
@import './lib/input.css';
|
@import './lib/input.css';
|
||||||
|
|||||||
@ -183,11 +183,14 @@ main {
|
|||||||
|
|
||||||
/* main content wrapper */
|
/* main content wrapper */
|
||||||
article {
|
article {
|
||||||
@apply bg-white grid grid-cols-1 ml-2 rounded-md;
|
@apply bg-white grid grid-cols-1 ml-2 rounded-md z-2;
|
||||||
@apply overflow-y-auto;
|
@apply overflow-y-auto;
|
||||||
grid-template-rows: 5rem auto;
|
grid-template-rows: 5rem auto;
|
||||||
container: content / inline-size;
|
container: content / inline-size;
|
||||||
transition: margin 200ms ease-in-out, width 200ms ease-in-out;
|
transition:
|
||||||
|
margin 250ms ease-in-out,
|
||||||
|
width 250ms ease-in-out,
|
||||||
|
padding 250ms ease-in-out;
|
||||||
|
|
||||||
/* main content title and actions */
|
/* main content title and actions */
|
||||||
header {
|
header {
|
||||||
@ -259,8 +262,8 @@ main {
|
|||||||
@apply ml-1 opacity-0 pointer-events-none transition-opacity duration-150;
|
@apply ml-1 opacity-0 pointer-events-none transition-opacity duration-150;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .calendar-expand-toggle,
|
h2:hover .calendar-expand-toggle,
|
||||||
&:focus-within .calendar-expand-toggle {
|
h2:focus-within .calendar-expand-toggle {
|
||||||
@apply opacity-100 pointer-events-auto;
|
@apply opacity-100 pointer-events-auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -281,21 +284,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* container sizing */
|
|
||||||
@container content (width >= 64rem)
|
|
||||||
{
|
|
||||||
main {
|
|
||||||
article {
|
|
||||||
header {
|
|
||||||
menu {
|
|
||||||
@apply relative top-auto right-auto h-auto w-auto rounded-none bg-transparent;
|
|
||||||
@apply flex flex-row items-center justify-end gap-4 p-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* app logo */
|
/* app logo */
|
||||||
.logo {
|
.logo {
|
||||||
@apply w-10 h-10 flex;
|
@apply w-10 h-10 flex;
|
||||||
@ -321,13 +309,29 @@ main {
|
|||||||
* desktop handling
|
* desktop handling
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* show app nav on the left at md */
|
/* menu handling */
|
||||||
|
@container content (width >= 64rem)
|
||||||
|
{
|
||||||
|
main {
|
||||||
|
article {
|
||||||
|
header {
|
||||||
|
menu {
|
||||||
|
@apply relative top-auto right-auto h-auto w-auto rounded-none bg-transparent;
|
||||||
|
@apply flex flex-row items-center justify-end gap-4 p-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* default desktop handling */
|
||||||
@media (width >= theme(--breakpoint-md))
|
@media (width >= theme(--breakpoint-md))
|
||||||
{
|
{
|
||||||
body#app {
|
body#app {
|
||||||
grid-template-columns: 5rem auto;
|
grid-template-columns: 5rem auto;
|
||||||
grid-template-rows: 1fr 0;
|
grid-template-rows: 1fr 0;
|
||||||
|
|
||||||
|
/* show app nav on the left at md */
|
||||||
> nav {
|
> nav {
|
||||||
@apply relative w-20 flex-col items-center justify-between px-0 pb-0 order-0;
|
@apply relative w-20 flex-col items-center justify-between px-0 pb-0 order-0;
|
||||||
|
|
||||||
@ -354,6 +358,7 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* main content with and without asides */
|
||||||
main {
|
main {
|
||||||
|
|
||||||
&:has(aside) {
|
&:has(aside) {
|
||||||
@ -363,7 +368,11 @@ main {
|
|||||||
|
|
||||||
aside {
|
aside {
|
||||||
@apply bg-white overflow-y-auto h-full min-w-48;
|
@apply bg-white overflow-y-auto h-full min-w-48;
|
||||||
transition: translate 200ms ease-in-out, visibility 200ms ease-in-out, opacity 200ms ease-in-out;
|
transition:
|
||||||
|
translate 250ms ease-in-out,
|
||||||
|
visibility 250ms ease-in-out,
|
||||||
|
opacity 250ms ease-in-out,
|
||||||
|
scale 250ms ease-in-out;
|
||||||
|
|
||||||
> h1 {
|
> h1 {
|
||||||
@apply backdrop-blur-xs sticky top-0 z-1 shrink-0 h-20 min-h-20;
|
@apply backdrop-blur-xs sticky top-0 z-1 shrink-0 h-20 min-h-20;
|
||||||
@ -373,19 +382,27 @@ main {
|
|||||||
|
|
||||||
article {
|
article {
|
||||||
@apply w-full ml-0 pl-3 2xl:pl-4 pr-6 2xl:pr-8 rounded-l-none rounded-r-lg;
|
@apply w-full ml-0 pl-3 2xl:pl-4 pr-6 2xl:pr-8 rounded-l-none rounded-r-lg;
|
||||||
|
|
||||||
|
&#calendar {
|
||||||
|
@apply pl-0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* when the calendar is expanded and aside is gone */
|
/* when the calendar is expanded and aside is gone */
|
||||||
&.expanded {
|
&.expanded {
|
||||||
|
|
||||||
aside {
|
aside {
|
||||||
@apply -translate-x-6 invisible opacity-0;
|
@apply translate-x-8 invisible opacity-0 scale-y-95;
|
||||||
}
|
}
|
||||||
|
|
||||||
article {
|
article {
|
||||||
@apply pl-6;
|
@apply pl-6 pr-6;
|
||||||
margin-left: min(-16rem, -20dvw) !important;
|
margin-left: min(-16rem, -20dvw) !important;
|
||||||
width: 100cqw !important;
|
width: 100cqw !important;
|
||||||
|
|
||||||
|
&#calendar {
|
||||||
|
@apply pl-6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -394,6 +411,13 @@ main {
|
|||||||
|
|
||||||
/* increase size of some things at 2xl */
|
/* increase size of some things at 2xl */
|
||||||
@media (width >= theme(--breakpoint-2xl)) { /* 96rem */
|
@media (width >= theme(--breakpoint-2xl)) { /* 96rem */
|
||||||
|
body#app main {
|
||||||
|
aside {
|
||||||
|
h1 {
|
||||||
|
@apply h-22;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
main {
|
main {
|
||||||
aside {
|
aside {
|
||||||
> h1 {
|
> h1 {
|
||||||
|
|||||||
@ -63,6 +63,10 @@ button,
|
|||||||
|
|
||||||
&.button--sm {
|
&.button--sm {
|
||||||
@apply text-base px-2 h-8;
|
@apply text-base px-2 h-8;
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
@apply w-4 h-4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,11 @@
|
|||||||
**/
|
**/
|
||||||
.calendar.month {
|
.calendar.month {
|
||||||
@apply grid col-span-3 pb-6 2xl:pb-8 pt-2;
|
@apply grid col-span-3 pb-6 2xl:pb-8 pt-2;
|
||||||
grid-template-rows: 2rem 1fr;
|
/*grid-template-rows: 2rem 1fr; */
|
||||||
|
|
||||||
|
/* force month container to fit window */
|
||||||
|
grid-template-rows: 2rem calc(100% - 2rem);
|
||||||
|
overflow-y: hidden;
|
||||||
|
|
||||||
hgroup {
|
hgroup {
|
||||||
@apply grid grid-cols-7 w-full gap-1;
|
@apply grid grid-cols-7 w-full gap-1;
|
||||||
@ -17,12 +21,16 @@
|
|||||||
@apply grid grid-cols-7 w-full gap-1;
|
@apply grid grid-cols-7 w-full gap-1;
|
||||||
contain: paint;
|
contain: paint;
|
||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
|
/*max-height: var(--month-calendar-height); */
|
||||||
|
|
||||||
|
/* day element */
|
||||||
li {
|
li {
|
||||||
@apply relative px-1 pt-8 border-t-md border-gray-900;
|
@apply bg-white relative px-1 pt-8 border-t-md border-gray-900 overflow-y-auto;
|
||||||
|
|
||||||
|
/* day number */
|
||||||
&::before {
|
&::before {
|
||||||
@apply absolute top-0 right-px w-auto h-8 flex items-center justify-end pr-4 text-sm font-medium;
|
@apply sticky top-0 -mt-8 -translate-y-8 right-0 w-auto h-8 z-1;
|
||||||
|
@apply flex items-center justify-end pr-3 text-sm font-medium bg-inherit;
|
||||||
content: attr(data-day-number);
|
content: attr(data-day-number);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,7 +90,7 @@
|
|||||||
.calendar.time {
|
.calendar.time {
|
||||||
@apply grid;
|
@apply grid;
|
||||||
grid-template-columns: 6rem auto;
|
grid-template-columns: 6rem auto;
|
||||||
grid-template-rows: 4.5rem auto 5rem;
|
grid-template-rows: 5rem auto 5rem;
|
||||||
--row-height: 2.5rem;
|
--row-height: 2.5rem;
|
||||||
--now-row: 1;
|
--now-row: 1;
|
||||||
--now-col-start: 1;
|
--now-col-start: 1;
|
||||||
@ -91,7 +99,7 @@
|
|||||||
/* top day bar */
|
/* top day bar */
|
||||||
hgroup {
|
hgroup {
|
||||||
@apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10;
|
@apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10;
|
||||||
top: 5.5rem;
|
@apply top-20;
|
||||||
|
|
||||||
span.name {
|
span.name {
|
||||||
@apply font-semibold uppercase text-sm;
|
@apply font-semibold uppercase text-sm;
|
||||||
@ -107,11 +115,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.day-header {
|
div.day-header {
|
||||||
@apply relative flex flex-col gap-2px justify-start items-start;
|
@apply relative flex flex-col gap-1 justify-end items-start pb-2;
|
||||||
animation: header-slide 250ms ease-in;
|
animation: header-slide 250ms ease-in;
|
||||||
|
|
||||||
&:not(:last-of-type)::after {
|
&:not(:last-of-type)::after {
|
||||||
@apply block w-px bg-gray-200 absolute -right-2 top-18;
|
@apply block w-px bg-gray-200 absolute -right-2 top-20;
|
||||||
content: '';
|
content: '';
|
||||||
height: calc(100dvh - 16rem);
|
height: calc(100dvh - 16rem);
|
||||||
}
|
}
|
||||||
@ -158,13 +166,16 @@
|
|||||||
--event-fg: var(--color-primary);
|
--event-fg: var(--color-primary);
|
||||||
|
|
||||||
li.event {
|
li.event {
|
||||||
@apply flex rounded-md relative border border-white;
|
@apply flex rounded-md relative border border-white overflow-hidden;
|
||||||
background-color: var(--event-bg);
|
background-color: var(--event-bg);
|
||||||
color: var(--event-fg);
|
color: var(--event-fg);
|
||||||
grid-row-start: var(--event-row);
|
grid-row-start: var(--event-row);
|
||||||
grid-row-end: var(--event-end);
|
grid-row-end: var(--event-end);
|
||||||
grid-column-start: var(--event-col);
|
grid-column-start: var(--event-col);
|
||||||
grid-column-end: calc(var(--event-col) + 1);
|
grid-column-end: calc(var(--event-col) + 1);
|
||||||
|
width: calc(100% - var(--event-overlap-offset, 100%));
|
||||||
|
margin-left: var(--event-overlap-offset, 0%);
|
||||||
|
z-index: var(--event-z, 1);
|
||||||
top: 0.6rem;
|
top: 0.6rem;
|
||||||
|
|
||||||
a.event {
|
a.event {
|
||||||
@ -175,12 +186,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
> time {
|
> time {
|
||||||
@apply text-xs;
|
@apply text-2xs font-medium whitespace-nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
animation: event-hover 125ms ease forwards;
|
/*animation: event-hover 125ms ease forwards;*/
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition:
|
||||||
|
transform 125ms ease-in;
|
||||||
|
/*width: 100%;*/
|
||||||
|
/*z-index: 20; /* enough to make sure it's always on top of other events */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,7 +204,7 @@
|
|||||||
/* bottom controls */
|
/* bottom controls */
|
||||||
footer {
|
footer {
|
||||||
@apply bg-white flex items-center justify-between col-span-2 border-t-md border-primary;
|
@apply bg-white flex items-center justify-between col-span-2 border-t-md border-primary;
|
||||||
@apply sticky bottom-0 pt-2 pb-8 z-10;
|
@apply sticky bottom-0 pt-2 pb-6 z-10;
|
||||||
|
|
||||||
a.timezone {
|
a.timezone {
|
||||||
@apply text-xs bg-gray-100 rounded px-2 py-1;
|
@apply text-xs bg-gray-100 rounded px-2 py-1;
|
||||||
@ -231,6 +247,9 @@
|
|||||||
|
|
||||||
&.week {
|
&.week {
|
||||||
ol.events {
|
ol.events {
|
||||||
|
a.event {
|
||||||
|
@apply p-2;
|
||||||
|
}
|
||||||
li.event[data-span="1"] {
|
li.event[data-span="1"] {
|
||||||
a.event > span,
|
a.event > span,
|
||||||
a.event > time {
|
a.event > time {
|
||||||
@ -239,8 +258,11 @@
|
|||||||
}
|
}
|
||||||
li.event[data-span="1"],
|
li.event[data-span="1"],
|
||||||
li.event[data-span="2"] {
|
li.event[data-span="2"] {
|
||||||
> a.event {
|
a.event {
|
||||||
@apply flex-row items-center gap-3;
|
@apply flex-col gap-0 py-1;
|
||||||
|
> span {
|
||||||
|
@apply min-h-4 line-clamp-1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -290,7 +312,7 @@
|
|||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
&[data-weekstart="0"] {
|
&[data-weekstart="1"] {
|
||||||
ol.events {
|
ol.events {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
@ -336,7 +358,7 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* calendar list in the left bar
|
* calendar list in the left bar
|
||||||
**/
|
*/
|
||||||
#calendar-toggles {
|
#calendar-toggles {
|
||||||
@apply pb-6;
|
@apply pb-6;
|
||||||
|
|
||||||
@ -363,7 +385,7 @@
|
|||||||
|
|
||||||
/* hide the edit link by default */
|
/* hide the edit link by default */
|
||||||
.edit-link {
|
.edit-link {
|
||||||
@apply hidden absolute pl-4 right-0 top-1/2 -translate-y-1/2 underline text-sm;
|
@apply hidden absolute pl-4 right-1 top-1/2 -translate-y-1/2 underline text-sm;
|
||||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 33%);
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 33%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,9 +403,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* media queries
|
||||||
|
*/
|
||||||
|
@media (width >= theme(--breakpoint-2xl)) /* 96rem */
|
||||||
|
{
|
||||||
|
.calendar.time {
|
||||||
|
hgroup {
|
||||||
|
@apply top-22;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (height <= 50rem)
|
||||||
|
{
|
||||||
|
.calendar.time {
|
||||||
|
grid-template-rows: 4rem auto 5rem;
|
||||||
|
|
||||||
|
hgroup {
|
||||||
|
div.day-header {
|
||||||
|
@apply flex-row items-center justify-start gap-2;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-of-type)::after {
|
||||||
|
@apply top-16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* animations
|
* animations
|
||||||
**/
|
*/
|
||||||
@keyframes event-slide {
|
@keyframes event-slide {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@ -401,7 +455,7 @@
|
|||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
z-index: 2;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@keyframes header-slide {
|
@keyframes header-slide {
|
||||||
|
|||||||
6
resources/css/lib/event.css
Normal file
6
resources/css/lib/event.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.event-map {
|
||||||
|
@apply relative -translate-y-20 -mb-12 -ml-6 w-full bg-cover;
|
||||||
|
aspect-ratio: 2 / 1;
|
||||||
|
background-image: var(--event-map);
|
||||||
|
width: calc(100% + 3rem);
|
||||||
|
}
|
||||||
@ -28,7 +28,7 @@ dialog {
|
|||||||
box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
|
box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
> .close-modal {
|
> .close-modal {
|
||||||
@apply block absolute top-4 right-4;
|
@apply block absolute top-4 right-4 z-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .content {
|
> .content {
|
||||||
@ -36,7 +36,7 @@ dialog {
|
|||||||
|
|
||||||
/* modal header */
|
/* modal header */
|
||||||
header {
|
header {
|
||||||
@apply px-6 py-6;
|
@apply relative flex items-center px-6 h-20 z-2;
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
@apply pr-12;
|
@apply pr-12;
|
||||||
@ -57,6 +57,13 @@ dialog {
|
|||||||
footer {
|
footer {
|
||||||
@apply px-6 py-4 border-t-md border-gray-400 flex justify-between;
|
@apply px-6 py-4 border-t-md border-gray-400 flex justify-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* event modal with a map */
|
||||||
|
&.with-map {
|
||||||
|
header {
|
||||||
|
background: linear-gradient(180deg,rgba(255, 255, 255, 0.67) 0%, rgba(255, 255, 255, 0) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,40 @@ document.addEventListener('htmx:configRequest', (evt) => {
|
|||||||
if (token) evt.detail.headers['X-CSRF-TOKEN'] = token
|
if (token) evt.detail.headers['X-CSRF-TOKEN'] = token
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* global auth expiry redirect (fetch/axios)
|
||||||
|
*/
|
||||||
|
const AUTH_REDIRECT_STATUSES = new Set([401, 419]);
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.assign('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.fetch) {
|
||||||
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = async (...args) => {
|
||||||
|
const response = await originalFetch(...args);
|
||||||
|
if (response && AUTH_REDIRECT_STATUSES.has(response.status)) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.axios) {
|
||||||
|
window.axios.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
if (AUTH_REDIRECT_STATUSES.has(status)) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* calendar ui
|
* calendar ui
|
||||||
* progressive enhancement on html form with no js
|
* progressive enhancement on html form with no js
|
||||||
|
|||||||
@ -23,9 +23,26 @@
|
|||||||
>
|
>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
@foreach ($hgroup as $h)
|
@foreach ($hgroup as $h)
|
||||||
|
@php
|
||||||
|
$dayParams = [
|
||||||
|
'view' => 'day',
|
||||||
|
'date' => $h['date'],
|
||||||
|
'density' => $density['step'],
|
||||||
|
'daytime_hours' => (int) ($daytime_hours['enabled'] ?? 0),
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
<div data-date="{{ $h['date'] }}" @class(['day-header', 'active' => $h['is_today'] ?? false])>
|
<div data-date="{{ $h['date'] }}" @class(['day-header', 'active' => $h['is_today'] ?? false])>
|
||||||
<span class="name">{{ $h['dow'] }}</span>
|
<span class="name">{{ $h['dow'] }}</span>
|
||||||
<a class="number" href="#">{{ $h['day'] }}</a>
|
<a class="number"
|
||||||
|
href="{{ route('calendar.index', $dayParams) }}"
|
||||||
|
hx-get="{{ route('calendar.index', $dayParams) }}"
|
||||||
|
hx-target="#calendar"
|
||||||
|
hx-select="#calendar"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-include="#calendar-toggles">
|
||||||
|
{{ $h['day'] }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</hgroup>
|
</hgroup>
|
||||||
|
|||||||
@ -14,6 +14,11 @@
|
|||||||
--event-col: {{ $event['start_col'] }};
|
--event-col: {{ $event['start_col'] }};
|
||||||
--event-bg: {{ $event['color'] }};
|
--event-bg: {{ $event['color'] }};
|
||||||
--event-fg: {{ $event['color_fg'] }};
|
--event-fg: {{ $event['color_fg'] }};
|
||||||
|
--event-overlap-count: {{ $event['overlap_count'] ?? 1 }};
|
||||||
|
--event-overlap-index: {{ $event['overlap_index'] ?? 0 }};
|
||||||
|
--event-overlap-width: {{ $event['overlap_width'] ?? 100 }}%;
|
||||||
|
--event-overlap-offset: {{ $event['overlap_offset'] ?? 0 }}%;
|
||||||
|
--event-z: {{ $event['overlap_z'] ?? 1 }};
|
||||||
">
|
">
|
||||||
@php
|
@php
|
||||||
$showParams = [$event['calendar_slug'], $event['id']];
|
$showParams = [$event['calendar_slug'], $event['id']];
|
||||||
|
|||||||
@ -25,9 +25,26 @@
|
|||||||
>
|
>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
@foreach ($hgroup as $h)
|
@foreach ($hgroup as $h)
|
||||||
|
@php
|
||||||
|
$dayParams = [
|
||||||
|
'view' => 'day',
|
||||||
|
'date' => $h['date'],
|
||||||
|
'density' => $density['step'],
|
||||||
|
'daytime_hours' => (int) ($daytime_hours['enabled'] ?? 0),
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
<div data-date="{{ $h['date'] }}" @class(['day-header', 'active' => $h['is_today'] ?? false])>
|
<div data-date="{{ $h['date'] }}" @class(['day-header', 'active' => $h['is_today'] ?? false])>
|
||||||
<span class="name">{{ $h['dow'] }}</span>
|
<span class="name">{{ $h['dow'] }}</span>
|
||||||
<a class="number" href="#">{{ $h['day'] }}</a>
|
<a class="number"
|
||||||
|
href="{{ route('calendar.index', $dayParams) }}"
|
||||||
|
hx-get="{{ route('calendar.index', $dayParams) }}"
|
||||||
|
hx-target="#calendar"
|
||||||
|
hx-select="#calendar"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-include="#calendar-toggles">
|
||||||
|
{{ $h['day'] }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</hgroup>
|
</hgroup>
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
<section class="flex flex-col px-8 pb-6">
|
<section {{ $attributes->class(['flex flex-col px-8 pb-6']) }}>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -3,6 +3,6 @@
|
|||||||
<x-icon-x />
|
<x-icon-x />
|
||||||
</x-button.icon>
|
</x-button.icon>
|
||||||
</form>
|
</form>
|
||||||
<div class="content">
|
<div {{ $attributes->class('content') }}>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
'border' => false,
|
'border' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
<header @class(['header--with-border' => $border])>
|
<header {{ $attributes->class(['header--with-border' => $border]) }}>
|
||||||
<h2>
|
{{ $slot }}
|
||||||
{{ $slot }}
|
|
||||||
</h2>
|
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -2,8 +2,7 @@
|
|||||||
$meta = $event->meta;
|
$meta = $event->meta;
|
||||||
$title = $meta->title ?? '(no title)';
|
$title = $meta->title ?? '(no title)';
|
||||||
$allDay = (bool) ($meta->all_day ?? false);
|
$allDay = (bool) ($meta->all_day ?? false);
|
||||||
$calendarName = $calendar->displayname ?? __('common.calendar');
|
$calendarName = $calendarName ?? $calendar->displayname ?? __('common.calendar');
|
||||||
$calendarColor = $calendar->meta_color ?? $calendar->calendarcolor ?? default_calendar_color();
|
|
||||||
$rrule = $meta?->extra['rrule'] ?? null;
|
$rrule = $meta?->extra['rrule'] ?? null;
|
||||||
$tzid = $meta?->extra['tzid'] ?? $tz;
|
$tzid = $meta?->extra['tzid'] ?? $tz;
|
||||||
$locationLabel = $meta?->location_label ?? '';
|
$locationLabel = $meta?->location_label ?? '';
|
||||||
@ -16,16 +15,23 @@
|
|||||||
$venue?->postal,
|
$venue?->postal,
|
||||||
])));
|
])));
|
||||||
$addressLine3 = $venue?->country;
|
$addressLine3 = $venue?->country;
|
||||||
|
$map = $map ?? ['enabled' => false, 'needs_key' => false, 'url' => null];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-modal.content>
|
<x-modal.content :class="$map['enabled'] ? 'with-map' : null">
|
||||||
<x-modal.title>
|
<x-modal.title class="gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<span class="inline-block h-4 w-4 rounded-full" style="background: {{ $event->color }};"></span>
|
||||||
<span class="inline-block h-3 w-3 rounded-full" style="background: {{ $calendarColor }};"></span>
|
<h2>{{ $title }}</h2>
|
||||||
<span>{{ $title }}</span>
|
|
||||||
</div>
|
|
||||||
</x-modal.title>
|
</x-modal.title>
|
||||||
<x-modal.body>
|
<x-modal.body>
|
||||||
|
@if ($map['enabled'])
|
||||||
|
<div class="event-map" style="--event-map: url('{{ trim($map['url']) }}');" aria-label="Map of event location">
|
||||||
|
<!-- map tile is background image-->
|
||||||
|
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="h-3 w-3 rounded-full bg-magenta-500 ring-4 ring-magenta-500/20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<section class="space-y-1">
|
<section class="space-y-1">
|
||||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.when') }}</p>
|
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.when') }}</p>
|
||||||
@ -74,9 +80,6 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div class="mt-2 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-4 text-sm text-gray-500">
|
|
||||||
{{ __('calendar.event.map_coming') }}
|
|
||||||
</div>
|
|
||||||
@else
|
@else
|
||||||
<p class="text-sm text-gray-500">{{ __('calendar.event.no_location') }}</p>
|
<p class="text-sm text-gray-500">{{ __('calendar.event.no_location') }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user