Adds support for events occuring at the same time or overlapping; improvements to event display and overlapping event handling; improvements to month view sizing; cleans up geocoder service
This commit is contained in:
parent
5563826a08
commit
25515eccb9
@ -103,11 +103,24 @@ class EventController extends Controller
|
||||
|
||||
$this->authorize('view', $event);
|
||||
|
||||
$event->load(['meta', 'meta.venue']);
|
||||
$event->loadMissing(['meta', 'meta.venue']);
|
||||
|
||||
$isHtmx = $request->header('HX-Request') === 'true';
|
||||
$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
|
||||
$occurrenceParam = $request->query('occurrence');
|
||||
$occurrenceStart = null;
|
||||
@ -136,7 +149,12 @@ class EventController extends Controller
|
||||
$start = $startUtc->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
|
||||
? view('event.partials.details', $data)
|
||||
@ -412,4 +430,53 @@ class EventController extends Controller
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,13 +69,31 @@ class GeocodeEventLocations implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
// geocode the free-form location string
|
||||
$norm = $geocoder->forward($meta->location);
|
||||
// geocode the free-form location string; if it looks like a label,
|
||||
// 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
|
||||
if (!$norm || (!$norm['lat'] && !$norm['street'])) {
|
||||
$skipped++;
|
||||
$processed++;
|
||||
Log::info('GeocodeEventLocations: skipped', [
|
||||
'event_id' => $meta->event_id,
|
||||
'location' => $meta->location,
|
||||
'query' => $query,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -154,9 +154,11 @@ class CalendarViewBuilder
|
||||
})->filter();
|
||||
|
||||
// ensure chronological ordering across calendars for all views
|
||||
return $payloads
|
||||
$payloads = $payloads
|
||||
->sortBy('start')
|
||||
->keyBy('occurrence_id');
|
||||
|
||||
return $this->applyOverlapLayout($payloads, $view);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -457,4 +459,105 @@ class CalendarViewBuilder
|
||||
$rounded = (int) round($minutes / $slot) * $slot;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,6 +93,29 @@ class Geocoder
|
||||
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
|
||||
*/
|
||||
@ -132,6 +155,7 @@ class Geocoder
|
||||
];
|
||||
|
||||
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
||||
$this->logArcgisResponse('zip-bias', $params, $res);
|
||||
if (!$res->ok()) {
|
||||
Log::info('arcgis zip bias lookup failed', ['zip' => $zip, 'status' => $res->status()]);
|
||||
return null;
|
||||
@ -199,6 +223,7 @@ class Geocoder
|
||||
}
|
||||
|
||||
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
||||
$this->logArcgisResponse('forward', $params, $res);
|
||||
if (!$res->ok()) {
|
||||
Log::warning('arcgis forward geocode failed', ['status' => $res->status(), 'q' => $query]);
|
||||
return null;
|
||||
@ -206,6 +231,9 @@ class Geocoder
|
||||
|
||||
$cands = $res->json('candidates', []);
|
||||
if (!$cands) {
|
||||
if ($this->arcgisDebugEnabled()) {
|
||||
Log::info('arcgis forward geocode empty', ['q' => $query]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -238,6 +266,7 @@ class Geocoder
|
||||
}
|
||||
|
||||
$res = $this->http()->get($this->arcgisBase().'/reverseGeocode', $params);
|
||||
$this->logArcgisResponse('reverse', $params, $res);
|
||||
if (!$res->ok()) {
|
||||
Log::warning('arcgis reverse geocode failed', ['status' => $res->status()]);
|
||||
return null;
|
||||
@ -344,6 +373,7 @@ class Geocoder
|
||||
$this->arcgisBase() . "/findAddressCandidates",
|
||||
$params,
|
||||
);
|
||||
$this->logArcgisResponse('suggestions', $params, $res);
|
||||
if (!$res->ok()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\HtmxAwareAuthenticate;
|
||||
use App\Http\Middleware\SetUserLocale;
|
||||
use App\Jobs\GeocodeEventLocations;
|
||||
use App\Jobs\SyncSubscriptionsDispatcher;
|
||||
@ -21,6 +22,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->web(append: [
|
||||
SetUserLocale::class,
|
||||
]);
|
||||
|
||||
// ensure HTMX requests redirect in the top-level window on auth expiry
|
||||
$middleware->alias([
|
||||
'auth' => HtmxAwareAuthenticate::class,
|
||||
]);
|
||||
})
|
||||
|
||||
->withSchedule(function (Schedule $schedule) {
|
||||
|
||||
@ -42,6 +42,7 @@ return [
|
||||
"arcgis" => [
|
||||
"api_key" => env("ARCGIS_API_KEY"),
|
||||
"store" => (bool) env("ARCGIS_STORE_RESULTS", true),
|
||||
"debug" => (bool) env("ARCGIS_DEBUG", false),
|
||||
"endpoint" =>
|
||||
"https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer",
|
||||
"country_code" => env("GEOCODER_COUNTRY", "USA"),
|
||||
@ -49,6 +50,8 @@ return [
|
||||
"out_fields" =>
|
||||
"Match_addr,Addr_type,PlaceName,Place_addr,Address,City,Region,Postal,CountryCode,LongLabel",
|
||||
"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($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
|
||||
$recurringStart = $now->copy()->next(Carbon::MONDAY)->setTime(18, 30);
|
||||
$insertRecurringEvent(
|
||||
|
||||
@ -53,6 +53,8 @@ return [
|
||||
'all_day' => 'All day',
|
||||
'location' => 'Location',
|
||||
'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.',
|
||||
'details' => 'Details',
|
||||
'repeats' => 'Repeats',
|
||||
|
||||
@ -53,6 +53,8 @@ return [
|
||||
'all_day' => 'Tutto il giorno',
|
||||
'location' => 'Luogo',
|
||||
'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.',
|
||||
'details' => 'Dettagli',
|
||||
'repeats' => 'Ripete',
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
@import './lib/calendar.css';
|
||||
@import './lib/checkbox.css';
|
||||
@import './lib/color.css';
|
||||
@import './lib/event.css';
|
||||
@import './lib/icon.css';
|
||||
@import './lib/indicator.css';
|
||||
@import './lib/input.css';
|
||||
|
||||
@ -183,11 +183,14 @@ main {
|
||||
|
||||
/* main content wrapper */
|
||||
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;
|
||||
grid-template-rows: 5rem auto;
|
||||
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 */
|
||||
header {
|
||||
@ -259,8 +262,8 @@ main {
|
||||
@apply ml-1 opacity-0 pointer-events-none transition-opacity duration-150;
|
||||
}
|
||||
|
||||
&:hover .calendar-expand-toggle,
|
||||
&:focus-within .calendar-expand-toggle {
|
||||
h2:hover .calendar-expand-toggle,
|
||||
h2:focus-within .calendar-expand-toggle {
|
||||
@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 */
|
||||
.logo {
|
||||
@apply w-10 h-10 flex;
|
||||
@ -321,13 +309,29 @@ main {
|
||||
* 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))
|
||||
{
|
||||
body#app {
|
||||
grid-template-columns: 5rem auto;
|
||||
grid-template-rows: 1fr 0;
|
||||
|
||||
/* show app nav on the left at md */
|
||||
> nav {
|
||||
@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 {
|
||||
|
||||
&:has(aside) {
|
||||
@ -363,7 +368,11 @@ main {
|
||||
|
||||
aside {
|
||||
@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 {
|
||||
@apply backdrop-blur-xs sticky top-0 z-1 shrink-0 h-20 min-h-20;
|
||||
@ -373,19 +382,27 @@ main {
|
||||
|
||||
article {
|
||||
@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 */
|
||||
&.expanded {
|
||||
|
||||
aside {
|
||||
@apply -translate-x-6 invisible opacity-0;
|
||||
@apply translate-x-8 invisible opacity-0 scale-y-95;
|
||||
}
|
||||
|
||||
article {
|
||||
@apply pl-6;
|
||||
@apply pl-6 pr-6;
|
||||
margin-left: min(-16rem, -20dvw) !important;
|
||||
width: 100cqw !important;
|
||||
|
||||
&#calendar {
|
||||
@apply pl-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -394,6 +411,13 @@ main {
|
||||
|
||||
/* increase size of some things at 2xl */
|
||||
@media (width >= theme(--breakpoint-2xl)) { /* 96rem */
|
||||
body#app main {
|
||||
aside {
|
||||
h1 {
|
||||
@apply h-22;
|
||||
}
|
||||
}
|
||||
}
|
||||
main {
|
||||
aside {
|
||||
> h1 {
|
||||
|
||||
@ -63,6 +63,10 @@ button,
|
||||
|
||||
&.button--sm {
|
||||
@apply text-base px-2 h-8;
|
||||
|
||||
> svg {
|
||||
@apply w-4 h-4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,11 @@
|
||||
**/
|
||||
.calendar.month {
|
||||
@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 {
|
||||
@apply grid grid-cols-7 w-full gap-1;
|
||||
@ -17,12 +21,16 @@
|
||||
@apply grid grid-cols-7 w-full gap-1;
|
||||
contain: paint;
|
||||
grid-auto-rows: 1fr;
|
||||
/*max-height: var(--month-calendar-height); */
|
||||
|
||||
/* day element */
|
||||
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 {
|
||||
@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);
|
||||
}
|
||||
|
||||
@ -82,7 +90,7 @@
|
||||
.calendar.time {
|
||||
@apply grid;
|
||||
grid-template-columns: 6rem auto;
|
||||
grid-template-rows: 4.5rem auto 5rem;
|
||||
grid-template-rows: 5rem auto 5rem;
|
||||
--row-height: 2.5rem;
|
||||
--now-row: 1;
|
||||
--now-col-start: 1;
|
||||
@ -91,7 +99,7 @@
|
||||
/* top day bar */
|
||||
hgroup {
|
||||
@apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10;
|
||||
top: 5.5rem;
|
||||
@apply top-20;
|
||||
|
||||
span.name {
|
||||
@apply font-semibold uppercase text-sm;
|
||||
@ -107,11 +115,11 @@
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
&: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: '';
|
||||
height: calc(100dvh - 16rem);
|
||||
}
|
||||
@ -158,13 +166,16 @@
|
||||
--event-fg: var(--color-primary);
|
||||
|
||||
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);
|
||||
color: var(--event-fg);
|
||||
grid-row-start: var(--event-row);
|
||||
grid-row-end: var(--event-end);
|
||||
grid-column-start: var(--event-col);
|
||||
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;
|
||||
|
||||
a.event {
|
||||
@ -175,12 +186,17 @@
|
||||
}
|
||||
|
||||
> time {
|
||||
@apply text-xs;
|
||||
@apply text-2xs font-medium whitespace-nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&: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 */
|
||||
footer {
|
||||
@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 {
|
||||
@apply text-xs bg-gray-100 rounded px-2 py-1;
|
||||
@ -231,6 +247,9 @@
|
||||
|
||||
&.week {
|
||||
ol.events {
|
||||
a.event {
|
||||
@apply p-2;
|
||||
}
|
||||
li.event[data-span="1"] {
|
||||
a.event > span,
|
||||
a.event > time {
|
||||
@ -239,8 +258,11 @@
|
||||
}
|
||||
li.event[data-span="1"],
|
||||
li.event[data-span="2"] {
|
||||
> a.event {
|
||||
@apply flex-row items-center gap-3;
|
||||
a.event {
|
||||
@apply flex-col gap-0 py-1;
|
||||
> span {
|
||||
@apply min-h-4 line-clamp-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -290,7 +312,7 @@
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
&[data-weekstart="0"] {
|
||||
&[data-weekstart="1"] {
|
||||
ol.events {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
@ -336,7 +358,7 @@
|
||||
|
||||
/**
|
||||
* calendar list in the left bar
|
||||
**/
|
||||
*/
|
||||
#calendar-toggles {
|
||||
@apply pb-6;
|
||||
|
||||
@ -363,7 +385,7 @@
|
||||
|
||||
/* hide the edit link by default */
|
||||
.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%);
|
||||
}
|
||||
|
||||
@ -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
|
||||
**/
|
||||
*/
|
||||
@keyframes event-slide {
|
||||
from {
|
||||
opacity: 0;
|
||||
@ -401,7 +455,7 @@
|
||||
}
|
||||
to {
|
||||
transform: translateY(-2px);
|
||||
z-index: 2;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
@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);
|
||||
|
||||
> .close-modal {
|
||||
@apply block absolute top-4 right-4;
|
||||
@apply block absolute top-4 right-4 z-3;
|
||||
}
|
||||
|
||||
> .content {
|
||||
@ -36,7 +36,7 @@ dialog {
|
||||
|
||||
/* modal header */
|
||||
header {
|
||||
@apply px-6 py-6;
|
||||
@apply relative flex items-center px-6 h-20 z-2;
|
||||
|
||||
h2 {
|
||||
@apply pr-12;
|
||||
@ -57,6 +57,13 @@ dialog {
|
||||
footer {
|
||||
@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
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
* progressive enhancement on html form with no js
|
||||
|
||||
@ -23,9 +23,26 @@
|
||||
>
|
||||
<hgroup>
|
||||
@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])>
|
||||
<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>
|
||||
@endforeach
|
||||
</hgroup>
|
||||
|
||||
@ -14,6 +14,11 @@
|
||||
--event-col: {{ $event['start_col'] }};
|
||||
--event-bg: {{ $event['color'] }};
|
||||
--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
|
||||
$showParams = [$event['calendar_slug'], $event['id']];
|
||||
|
||||
@ -25,9 +25,26 @@
|
||||
>
|
||||
<hgroup>
|
||||
@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])>
|
||||
<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>
|
||||
@endforeach
|
||||
</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 }}
|
||||
</section>
|
||||
|
||||
@ -3,6 +3,6 @@
|
||||
<x-icon-x />
|
||||
</x-button.icon>
|
||||
</form>
|
||||
<div class="content">
|
||||
<div {{ $attributes->class('content') }}>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
'border' => false,
|
||||
])
|
||||
|
||||
<header @class(['header--with-border' => $border])>
|
||||
<h2>
|
||||
{{ $slot }}
|
||||
</h2>
|
||||
<header {{ $attributes->class(['header--with-border' => $border]) }}>
|
||||
{{ $slot }}
|
||||
</header>
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
$meta = $event->meta;
|
||||
$title = $meta->title ?? '(no title)';
|
||||
$allDay = (bool) ($meta->all_day ?? false);
|
||||
$calendarName = $calendar->displayname ?? __('common.calendar');
|
||||
$calendarColor = $calendar->meta_color ?? $calendar->calendarcolor ?? default_calendar_color();
|
||||
$calendarName = $calendarName ?? $calendar->displayname ?? __('common.calendar');
|
||||
$rrule = $meta?->extra['rrule'] ?? null;
|
||||
$tzid = $meta?->extra['tzid'] ?? $tz;
|
||||
$locationLabel = $meta?->location_label ?? '';
|
||||
@ -16,16 +15,23 @@
|
||||
$venue?->postal,
|
||||
])));
|
||||
$addressLine3 = $venue?->country;
|
||||
$map = $map ?? ['enabled' => false, 'needs_key' => false, 'url' => null];
|
||||
@endphp
|
||||
|
||||
<x-modal.content>
|
||||
<x-modal.title>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-block h-3 w-3 rounded-full" style="background: {{ $calendarColor }};"></span>
|
||||
<span>{{ $title }}</span>
|
||||
</div>
|
||||
<x-modal.content :class="$map['enabled'] ? 'with-map' : null">
|
||||
<x-modal.title class="gap-4">
|
||||
<span class="inline-block h-4 w-4 rounded-full" style="background: {{ $event->color }};"></span>
|
||||
<h2>{{ $title }}</h2>
|
||||
</x-modal.title>
|
||||
<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">
|
||||
<section class="space-y-1">
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.when') }}</p>
|
||||
@ -74,9 +80,6 @@
|
||||
@endif
|
||||
</div>
|
||||
@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
|
||||
<p class="text-sm text-gray-500">{{ __('calendar.event.no_location') }}</p>
|
||||
@endif
|
||||
|
||||
Loading…
Reference in New Issue
Block a user