Adds week, day, and 4-day display views for calendar; adds new display preferences pane; moves settings to the user and not calendar; cleans up timezone and preference handling; improves button groups
This commit is contained in:
parent
5fd9628dc9
commit
1ad62dd0af
@ -17,11 +17,13 @@ use App\Services\Calendar\CreateCalendar;
|
||||
|
||||
class CalendarController extends Controller
|
||||
{
|
||||
private const VIEWS = ['day', 'week', 'month', 'four'];
|
||||
|
||||
/**
|
||||
* Consolidated calendar dashboard.
|
||||
*
|
||||
* Query params:
|
||||
* view = month | week | 4day (default: month)
|
||||
* view = month | week | four (default: month)
|
||||
* date = Y-m-d anchor date (default: today, in user TZ)
|
||||
*
|
||||
* The view receives a `$payload` array:
|
||||
@ -32,77 +34,78 @@ class CalendarController extends Controller
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
/**
|
||||
*
|
||||
* manage parameters and core variables
|
||||
*/
|
||||
|
||||
// set the calendar key
|
||||
$principal = auth()->user()->principal_uri;
|
||||
|
||||
// user settings
|
||||
$user = $request->user();
|
||||
$tz = $user->timezone ?? config('app.timezone');
|
||||
$timeFormat = $user->getSetting('app.time_format', '12');
|
||||
|
||||
// settings
|
||||
$defaultView = $user->getSetting('calendar.last_view', 'month');
|
||||
$defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString());
|
||||
$defaultDensity = (int) $user->getSetting('calendar.last_density', 30);
|
||||
|
||||
// week start preference
|
||||
$weekStartPref = $user->getSetting('calendar.week_start', 'sunday'); // 'sunday'|'monday'
|
||||
$weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true)
|
||||
? $weekStartPref
|
||||
: 'sunday';
|
||||
$weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY;
|
||||
$weekEnd = (int) (($weekStart + 6) % 7);
|
||||
|
||||
// get the view and time range
|
||||
[$view, $range] = $this->resolveRange($request);
|
||||
[$view, $range] = $this->resolveRange($request, $tz, $weekStart, $weekEnd, $defaultView, $defaultDate);
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
// date range span
|
||||
$span = $this->gridSpan($view, $range);
|
||||
// get the display density, if present (in minutes for each step)
|
||||
$stepMinutes = (int) $request->query('density', $defaultDensity);
|
||||
if (! in_array($stepMinutes, [15, 30, 60], true)) { // lock it down
|
||||
$stepMinutes = 30;
|
||||
}
|
||||
$labelEvery = match ($stepMinutes) { // how many 15-min slots per label/row
|
||||
15 => 1,
|
||||
30 => 2,
|
||||
60 => 4,
|
||||
};
|
||||
|
||||
// date range controls
|
||||
$prev = $range['start']->copy()->subMonth()->startOfMonth()->toDateString();
|
||||
$next = $range['start']->copy()->addMonth()->startOfMonth()->toDateString();
|
||||
$today = Carbon::today()->toDateString();
|
||||
// date range span and controls
|
||||
$span = $this->gridSpan($view, $range, $weekStart, $weekEnd);
|
||||
$nav = $this->navDates($view, $range['start'], $tz);
|
||||
|
||||
// get the user's visible calendars from the left bar
|
||||
$visible = collect($request->query('c', []));
|
||||
|
||||
// keep a stable anchor date for forms that aren't the nav buttons
|
||||
$anchorDate = $request->query('date', now($tz)->toDateString());
|
||||
|
||||
// persist settings
|
||||
if ($request->hasAny(['view', 'date', 'density'])) {
|
||||
$user->setSetting('calendar.last_view', $view);
|
||||
$user->setSetting('calendar.last_date', $range['start']->toDateString());
|
||||
$user->setSetting('calendar.last_density', (string) $stepMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* calendars
|
||||
*/
|
||||
|
||||
// load the user's local calendars
|
||||
$locals = Calendar::query()
|
||||
->select(
|
||||
'calendars.id',
|
||||
'ci.displayname',
|
||||
'ci.calendarcolor',
|
||||
'ci.uri as slug',
|
||||
'ci.timezone as timezone',
|
||||
'meta.color as meta_color',
|
||||
'meta.color_fg as meta_color_fg',
|
||||
DB::raw('0 as is_remote')
|
||||
)
|
||||
->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
|
||||
->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
|
||||
->where('ci.principaluri', $principal)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('meta.is_remote')
|
||||
->orWhere('meta.is_remote', false);
|
||||
})
|
||||
->orderBy('ci.displayname')
|
||||
$calendars = Calendar::query()
|
||||
->dashboardForPrincipal($principal)
|
||||
->get();
|
||||
|
||||
// load the users remote/subscription calendars
|
||||
$remotes = Calendar::query()
|
||||
->select(
|
||||
'calendars.id',
|
||||
'ci.displayname',
|
||||
'ci.calendarcolor',
|
||||
'ci.uri as slug',
|
||||
'ci.timezone as timezone',
|
||||
'meta.color as meta_color',
|
||||
'meta.color_fg as meta_color_fg',
|
||||
DB::raw('1 as is_remote')
|
||||
)
|
||||
->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
|
||||
->join('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
|
||||
->where('ci.principaluri', $principal)
|
||||
->where('meta.is_remote', true)
|
||||
->orderBy('ci.displayname')
|
||||
->get();
|
||||
|
||||
// merge local and remote, and add the visibility flag
|
||||
$visible = collect($request->query('c', []));
|
||||
$calendars = $locals->merge($remotes)->map(function ($cal) use ($visible) {
|
||||
$calendars = $calendars->map(function ($cal) use ($visible) {
|
||||
$cal->visible = $visible->isEmpty() || $visible->contains($cal->slug);
|
||||
return $cal;
|
||||
});
|
||||
|
||||
// handy lookup: [id => calendar row]
|
||||
$calendar_map = $calendars->keyBy('id');
|
||||
|
||||
/**
|
||||
@ -115,7 +118,7 @@ class CalendarController extends Controller
|
||||
$calendars->pluck('id'),
|
||||
$span['start'],
|
||||
$span['end']
|
||||
)->map(function ($e) use ($calendar_map) {
|
||||
)->map(function ($e) use ($calendar_map, $timeFormat, $view, $range, $tz, $weekStart, $weekEnd) {
|
||||
|
||||
// event's calendar
|
||||
$cal = $calendar_map[$e->calendarid];
|
||||
@ -126,11 +129,27 @@ class CalendarController extends Controller
|
||||
$end_utc = $e->meta->end_at ??
|
||||
($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
|
||||
|
||||
// time format handling
|
||||
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
|
||||
|
||||
// convert to calendar timezone
|
||||
$timezone = $calendar_map[$e->calendarid]->timezone ?? config('app.timezone');
|
||||
$start_local = $start_utc->copy()->timezone($timezone);
|
||||
$end_local = optional($end_utc)->copy()->timezone($timezone);
|
||||
|
||||
// convert utc to user tz for grid placement (columns/rows must match view headers)
|
||||
$start_for_grid = $start_utc->copy()->tz($tz);
|
||||
$end_for_grid = optional($end_utc)->copy()->tz($tz);
|
||||
|
||||
// placement for time-based layouts
|
||||
$placement = $this->slotPlacement(
|
||||
$start_for_grid,
|
||||
$end_for_grid,
|
||||
$range['start']->copy()->tz($tz),
|
||||
$view,
|
||||
15
|
||||
);
|
||||
|
||||
// color handling
|
||||
$color = $cal['meta_color']
|
||||
?? $cal['calendarcolor']
|
||||
@ -138,8 +157,17 @@ class CalendarController extends Controller
|
||||
$colorFg = $cal['meta_color_fg']
|
||||
?? contrast_text_color($color);
|
||||
|
||||
logger()->info('event times', [
|
||||
'id' => $e->id,
|
||||
'start_at' => optional($e->meta)->start_at,
|
||||
'end_at' => optional($e->meta)->end_at,
|
||||
'firstoccurence' => $e->firstoccurence,
|
||||
'lastoccurence' => $e->lastoccurence,
|
||||
]);
|
||||
|
||||
// return events array
|
||||
return [
|
||||
// core data
|
||||
'id' => $e->id,
|
||||
'calendar_id' => $e->calendarid,
|
||||
'calendar_slug' => $cal->slug,
|
||||
@ -147,12 +175,19 @@ class CalendarController extends Controller
|
||||
'description' => $e->meta->description ?? 'No description.',
|
||||
'start' => $start_utc->toIso8601String(),
|
||||
'end' => optional($end_utc)->toIso8601String(),
|
||||
'start_ui' => $start_local->format('g:ia'),
|
||||
'end_ui' => optional($end_local)->format('g:ia'),
|
||||
'start_ui' => $start_local->format($uiFormat),
|
||||
'end_ui' => optional($end_local)->format($uiFormat),
|
||||
'timezone' => $timezone,
|
||||
'visible' => $cal->visible,
|
||||
'color' => $color,
|
||||
'color_fg' => $colorFg,
|
||||
// slot placement for time-based grid
|
||||
'start_row' => $placement['start_row'],
|
||||
'end_row' => $placement['end_row'],
|
||||
'row_span' => $placement['row_span'],
|
||||
'start_col' => $placement['start_col'],
|
||||
'duration' => $placement['duration'],
|
||||
|
||||
];
|
||||
})->keyBy('id');
|
||||
|
||||
@ -163,17 +198,22 @@ class CalendarController extends Controller
|
||||
|
||||
// create the mini calendar grid based on the mini cal controls
|
||||
$mini_anchor = $request->query('mini', $range['start']->toDateString());
|
||||
$mini_start = Carbon::parse($mini_anchor)->startOfMonth();
|
||||
|
||||
// anchor is a DATE string, so create it explicitly in the user tz
|
||||
$mini_start = Carbon::createFromFormat('Y-m-d', $mini_anchor, $tz)
|
||||
->startOfMonth();
|
||||
|
||||
$mini_nav = [
|
||||
'prev' => $mini_start->copy()->subMonth()->toDateString(),
|
||||
'next' => $mini_start->copy()->addMonth()->toDateString(),
|
||||
'today' => Carbon::today()->startOfMonth()->toDateString(),
|
||||
'today' => Carbon::today($tz)->startOfMonth()->toDateString(),
|
||||
'label' => $mini_start->format('F Y'),
|
||||
];
|
||||
$mini_headers = $this->weekdayHeaders($tz, $weekStart);
|
||||
|
||||
// compute the mini's 42-day span (Mon..Sun, 6 rows)
|
||||
$mini_grid_start = $mini_start->copy()->startOfWeek(Carbon::MONDAY);
|
||||
$mini_grid_end = $mini_start->copy()->endOfMonth()->endOfWeek(Carbon::SUNDAY);
|
||||
$mini_grid_start = $mini_start->copy()->startOfWeek($weekStart);
|
||||
$mini_grid_end = $mini_start->copy()->endOfMonth()->endOfWeek($weekEnd);
|
||||
if ($mini_grid_start->diffInDays($mini_grid_end) + 1 < 42) {
|
||||
$mini_grid_end->addWeek();
|
||||
}
|
||||
@ -183,14 +223,12 @@ class CalendarController extends Controller
|
||||
$calendars->pluck('id'),
|
||||
$mini_grid_start,
|
||||
$mini_grid_end
|
||||
)->map(function ($e) use ($calendar_map) {
|
||||
)->map(function ($e) use ($calendar_map, $tz) {
|
||||
$cal = $calendar_map[$e->calendarid];
|
||||
|
||||
$start_utc = $e->meta->start_at ?? Carbon::createFromTimestamp($e->firstoccurence);
|
||||
$end_utc = $e->meta->end_at ?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
|
||||
|
||||
$tz = $cal->timezone ?? config('app.timezone');
|
||||
|
||||
$color = $cal->meta_color
|
||||
?? $cal->calendarcolor
|
||||
?? default_calendar_color();
|
||||
@ -213,7 +251,7 @@ class CalendarController extends Controller
|
||||
})->keyBy('id');
|
||||
|
||||
// now build the mini from mini_events (not from $events)
|
||||
$mini = $this->buildMiniGrid($mini_start, $mini_events);
|
||||
$mini = $this->buildMiniGrid($mini_start, $mini_events, $tz, $weekStart, $weekEnd);
|
||||
|
||||
/**
|
||||
*
|
||||
@ -221,22 +259,24 @@ class CalendarController extends Controller
|
||||
*/
|
||||
|
||||
// create the calendar grid of days
|
||||
$grid = $this->buildCalendarGrid($view, $range, $events);
|
||||
$grid = $this->buildCalendarGrid($view, $range, $events, $tz, $weekStart, $weekEnd);
|
||||
|
||||
// get the title
|
||||
$header = $this->headerTitle($view, $range['start'], $range['end']);
|
||||
|
||||
// format the data for the frontend, including separate arrays for events specifically and the big grid
|
||||
$payload = [
|
||||
'view' => $view,
|
||||
'range' => $range,
|
||||
'nav' => [
|
||||
'prev' => $prev,
|
||||
'next' => $next,
|
||||
'today' => $today,
|
||||
],
|
||||
'nav' => $nav,
|
||||
'header' => $header,
|
||||
'active' => [
|
||||
'date' => $range['start']->toDateString(),
|
||||
'year' => $range['start']->format('Y'),
|
||||
'month' => $range['start']->format("F"),
|
||||
'day' => $range['start']->format("d"),
|
||||
],
|
||||
'week_start' => $weekStart,
|
||||
'calendars' => $calendars->mapWithKeys(function ($cal)
|
||||
{
|
||||
// compute colors
|
||||
@ -258,10 +298,31 @@ class CalendarController extends Controller
|
||||
],
|
||||
];
|
||||
}),
|
||||
'hgroup' => $this->viewHeaders($view, $range, $tz, $weekStart),
|
||||
'events' => $events, // keyed, one copy each
|
||||
'grid' => $grid, // day objects hold only ID-sets
|
||||
'mini' => $mini, // mini calendar days with events for indicators
|
||||
'mini_nav' => $mini_nav, // separate mini calendar navigation
|
||||
'mini_headers' => $mini_headers,
|
||||
];
|
||||
|
||||
// time-based payload values
|
||||
$timeBased = in_array($view, ['day', 'week', 'four'], true);
|
||||
if ($timeBased)
|
||||
{
|
||||
// create the time gutter if we're in a time-based view
|
||||
$payload['slots'] = $this->timeSlots($range['start'], $tz, $timeFormat);
|
||||
$payload['time_format'] = $timeFormat; // optional, if the blade cares
|
||||
|
||||
// add the now indicator
|
||||
$payload['now'] = $this->nowIndicator($view, $range, $tz, 15);
|
||||
}
|
||||
|
||||
// send the density array always, even though it doesn't matter for month
|
||||
$payload['density'] = [
|
||||
'step' => $stepMinutes, // 15|30|60
|
||||
'label_every' => $labelEvery, // 1|2|4
|
||||
'anchor' => $anchorDate,
|
||||
];
|
||||
|
||||
return view('calendar.index', $payload);
|
||||
@ -375,7 +436,9 @@ class CalendarController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* delete calendar @todo
|
||||
*
|
||||
* Delete calendar
|
||||
* @todo
|
||||
*/
|
||||
public function destroy(Calendar $calendar)
|
||||
{
|
||||
@ -390,54 +453,91 @@ class CalendarController extends Controller
|
||||
*/
|
||||
|
||||
/**
|
||||
* Span actually rendered by the grid.
|
||||
* Month → startOfMonth->startOfWeek .. endOfMonth->endOfWeek
|
||||
*
|
||||
* Prepare nav dates for the movement buttons in the calendar header
|
||||
*
|
||||
* @return array
|
||||
* [
|
||||
* 'prev' => 2026-08-19
|
||||
* 'next' => 2026-08-21
|
||||
* 'today' => 2026-08-20
|
||||
* ]
|
||||
*/
|
||||
private function gridSpan(string $view, array $range): array
|
||||
private function navDates(string $view, Carbon $start, string $tz): array
|
||||
{
|
||||
switch ($view) {
|
||||
case 'week':
|
||||
$start = $range['start']->copy(); // resolveRange already did startOfWeek
|
||||
$end = $range['start']->copy()->addDays(6);
|
||||
break;
|
||||
// always compute in the user tz so the UX is consistent
|
||||
$start = $start->copy()->tz($tz);
|
||||
|
||||
case '4day':
|
||||
$start = $range['start']->copy(); // resolveRange already did startOfDay
|
||||
$end = $range['start']->copy()->addDays(3);
|
||||
break;
|
||||
|
||||
default: // month
|
||||
$start = $range['start']->copy()->startOfMonth()->startOfWeek();
|
||||
$end = $range['end']->copy()->endOfMonth()->endOfWeek();
|
||||
}
|
||||
|
||||
return ['start' => $start, 'end' => $end];
|
||||
return match ($view)
|
||||
{
|
||||
'day' => [
|
||||
'prev' => $start->copy()->subDay()->toDateString(),
|
||||
'next' => $start->copy()->addDay()->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
'week' => [
|
||||
'prev' => $start->copy()->subWeek()->toDateString(),
|
||||
'next' => $start->copy()->addWeek()->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
'four' => [
|
||||
'prev' => $start->copy()->subDays(4)->toDateString(),
|
||||
'next' => $start->copy()->addDays(4)->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
default => [ // month
|
||||
'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(),
|
||||
'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(),
|
||||
'today' => Carbon::today($tz)->toDateString(),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* normalise $view and $date into a carbon range
|
||||
*
|
||||
* @return array [$view, ['start' => Carbon, 'end' => Carbon]]
|
||||
* Interpret $view and $date filters and normalize into a Carbon range
|
||||
*
|
||||
* @return array
|
||||
* [
|
||||
* $view,
|
||||
* [
|
||||
* 'start' => Carbon,
|
||||
* 'end' => Carbon
|
||||
* ]
|
||||
* ]
|
||||
*/
|
||||
private function resolveRange(Request $request): array
|
||||
{
|
||||
private function resolveRange(
|
||||
Request $request,
|
||||
string $tz,
|
||||
int $weekStart,
|
||||
int $weekEnd,
|
||||
string $defaultView,
|
||||
string $defaultDate
|
||||
): array {
|
||||
// get the view
|
||||
$view = in_array($request->query('view'), ['week', '4day'])
|
||||
? $request->query('view')
|
||||
$requestView = $request->query('view', $defaultView);
|
||||
$view = in_array($requestView, self::VIEWS, true)
|
||||
? $requestView
|
||||
: 'month';
|
||||
$date = $request->query('date', $defaultDate);
|
||||
|
||||
// anchor date in the user's timezone
|
||||
$anchor = Carbon::parse($request->query('date', now()->toDateString()))
|
||||
->setTimezone(auth()->user()->timezone ?? config('app.timezone'));
|
||||
$anchor = Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay();
|
||||
|
||||
// set dates based on view
|
||||
switch ($view) {
|
||||
case 'week':
|
||||
$start = $anchor->copy()->startOfWeek();
|
||||
$end = $anchor->copy()->endOfWeek();
|
||||
switch ($view)
|
||||
{
|
||||
case 'day':
|
||||
$start = $anchor->copy()->startOfDay();
|
||||
$end = $anchor->copy()->endOfDay();
|
||||
break;
|
||||
|
||||
case '4day':
|
||||
case 'week':
|
||||
$start = $anchor->copy()->startOfWeek($weekStart);
|
||||
$end = $anchor->copy()->endOfWeek($weekEnd);
|
||||
break;
|
||||
|
||||
case 'four':
|
||||
// a rolling 4-day "agenda" view starting at anchor
|
||||
$start = $anchor->copy()->startOfDay();
|
||||
$end = $anchor->copy()->addDays(3)->endOfDay();
|
||||
@ -452,6 +552,183 @@ class CalendarController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the grid for each view with pre- and post-padding if needed
|
||||
*
|
||||
* This is different from resolveRange() as the rendered span != logical range
|
||||
*
|
||||
* Month: start of the month's first week to end of the last week
|
||||
* Week: Sunday to Saturday
|
||||
* Four: selected day to 3 days later
|
||||
* Day: start of the day to the end of the day
|
||||
*/
|
||||
private function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array
|
||||
{
|
||||
switch ($view)
|
||||
{
|
||||
case 'day':
|
||||
$start = $range['start']->copy()->startOfDay();
|
||||
$end = $range['start']->copy()->endOfDay();
|
||||
break;
|
||||
|
||||
case 'week':
|
||||
$start = $range['start']->copy()->startOfWeek($weekStart);
|
||||
$end = $range['start']->copy()->endOfWeek($weekEnd);
|
||||
break;
|
||||
|
||||
case 'four':
|
||||
$start = $range['start']->copy()->startOfDay();
|
||||
$end = $range['start']->copy()->addDays(3);
|
||||
break;
|
||||
|
||||
default: // month
|
||||
$start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart);
|
||||
$end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd);
|
||||
}
|
||||
|
||||
return ['start' => $start, 'end' => $end];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Prepare the calendar title based on the view
|
||||
*
|
||||
* @return array
|
||||
* [
|
||||
* 'strong' => 'August 20',
|
||||
* 'span' => '2026',
|
||||
* ]
|
||||
*/
|
||||
private function headerTitle(string $view, Carbon $start, Carbon $end): array
|
||||
{
|
||||
$sameDay = $start->isSameDay($end);
|
||||
$sameMonth = $start->isSameMonth($end);
|
||||
$sameYear = $start->isSameYear($end);
|
||||
|
||||
// month default
|
||||
$strong = $start->format('F');
|
||||
$span = $start->format('Y');
|
||||
|
||||
if ($view === 'day' || $sameDay) {
|
||||
return [
|
||||
'strong' => $start->format('F j'),
|
||||
'span' => $start->format('Y'),
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($view, ['week', 'four'], true)) {
|
||||
if ($sameMonth && $sameYear) {
|
||||
return [
|
||||
'strong' => $start->format('F j') . ' to ' . $end->format('j'),
|
||||
'span' => $start->format('Y'),
|
||||
];
|
||||
}
|
||||
|
||||
if ($sameYear) {
|
||||
return [
|
||||
'strong' => $start->format('F') . ' to ' . $end->format('F'),
|
||||
'span' => $start->format('Y'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'),
|
||||
'span' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return ['strong' => $strong, 'span' => $span];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Create the time gutter for time-based views
|
||||
*/
|
||||
private function timeSlots(Carbon $dayStart, string $tz, string $timeFormat): array
|
||||
{
|
||||
$minutesPerSlot = 15;
|
||||
$slotsPerDay = intdiv(24 * 60, $minutesPerSlot); // 96
|
||||
|
||||
$format = $timeFormat === '24' ? 'H:i' : 'g:i a';
|
||||
|
||||
$slots = [];
|
||||
$t = $dayStart->copy()->tz($tz)->startOfDay();
|
||||
|
||||
for ($i = 0; $i < $slotsPerDay; $i++) {
|
||||
$slots[] = [
|
||||
'iso' => $t->toIso8601String(),
|
||||
'label' => $t->format($format),
|
||||
'key' => $t->format('H:i'), // stable "machine" value
|
||||
'index' => $i, // 0..95
|
||||
'minutes' => $i * $minutesPerSlot,
|
||||
'duration' => $minutesPerSlot, // handy for styling math
|
||||
];
|
||||
|
||||
$t->addMinutes($minutesPerSlot);
|
||||
}
|
||||
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Time-based layout slot placement
|
||||
*
|
||||
* Placements object:
|
||||
* [
|
||||
* 'start_line' => '24',
|
||||
* 'end_line' => '32',
|
||||
* 'span' => '4',
|
||||
* 'duration' => '60',
|
||||
* ]
|
||||
**/
|
||||
private function slotPlacement(
|
||||
Carbon $startLocal,
|
||||
?Carbon $endLocal,
|
||||
Carbon $rangeStart,
|
||||
string $view,
|
||||
int $minutesPerSlot = 15
|
||||
): array
|
||||
{
|
||||
$start = $startLocal->copy();
|
||||
$end = ($endLocal ?? $startLocal)->copy();
|
||||
|
||||
// get the real duration in minutes
|
||||
$durationMinutes = max(0, $start->diffInMinutes($end, false));
|
||||
|
||||
// duration for display purposes
|
||||
$displayMinutes = $durationMinutes > 0 ? $durationMinutes : $minutesPerSlot;
|
||||
|
||||
// row placement (96 rows when minutesPerSlot=15)
|
||||
$startMinutesFromMidnight = ($start->hour * 60) + $start->minute;
|
||||
$startRow = intdiv($startMinutesFromMidnight, $minutesPerSlot) + 1;
|
||||
|
||||
$rowSpan = max(1, (int) ceil($displayMinutes / $minutesPerSlot));
|
||||
$endRow = $startRow + $rowSpan;
|
||||
|
||||
// column placement
|
||||
$maxCols = match ($view) {
|
||||
'day' => 1,
|
||||
'four' => 4,
|
||||
'week' => 7,
|
||||
default => 1, // month won’t use this
|
||||
};
|
||||
// rangeStart is already the "first day column" for week/four/day
|
||||
$startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1;
|
||||
|
||||
// clamp to view columns
|
||||
$startCol = max(1, min($maxCols, $startCol));
|
||||
|
||||
return [
|
||||
'start_row' => $startRow,
|
||||
'end_row' => $endRow,
|
||||
'row_span' => $rowSpan,
|
||||
'duration' => $durationMinutes,
|
||||
'start_col' => $startCol,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Assemble an array of day-objects for the requested view.
|
||||
*
|
||||
* Day object shape:
|
||||
@ -465,26 +742,28 @@ class CalendarController extends Controller
|
||||
* For the "month" view the return value also contains
|
||||
* 'weeks' => [ [7 day-objs], [7 day-objs], … ]
|
||||
*/
|
||||
private function buildCalendarGrid(string $view, array $range, Collection $events): array
|
||||
private function buildCalendarGrid(
|
||||
string $view,
|
||||
array $range,
|
||||
Collection $events,
|
||||
string $tz,
|
||||
int $weekStart,
|
||||
int $weekEnd): array
|
||||
{
|
||||
// use the same span the events were fetched for (month padded to full weeks, etc.)
|
||||
['start' => $grid_start, 'end' => $grid_end] = $this->gridSpan($view, $range);
|
||||
['start' => $grid_start, 'end' => $grid_end] = $this->gridSpan($view, $range, $weekStart, $weekEnd);
|
||||
|
||||
// today checks
|
||||
$tz = auth()->user()->timezone ?? config('app.timezone', 'UTC');
|
||||
$today = \Carbon\Carbon::today($tz);
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
// index events by YYYY-MM-DD for quick lookup
|
||||
$events_by_day = [];
|
||||
foreach ($events as $ev) {
|
||||
foreach ($events as $ev)
|
||||
{
|
||||
$evTz = $ev['timezone'] ?? $tz;
|
||||
|
||||
$start = Carbon::parse($ev['start'])->tz($evTz);
|
||||
$end = $ev['end']
|
||||
? Carbon::parse($ev['end'])->tz($evTz)
|
||||
: $start;
|
||||
$end = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $start;
|
||||
|
||||
// spread multi-day events
|
||||
for ($d = $start->copy()->startOfDay();
|
||||
$d->lte($end->copy()->endOfDay());
|
||||
$d->addDay()) {
|
||||
@ -514,7 +793,67 @@ class CalendarController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the mini-month grid for day buttons
|
||||
*
|
||||
* Create the specific view's date headers
|
||||
*
|
||||
* For day/week/four, this is an array of number dates and their day name,
|
||||
* for month it's the user's preferred weekdays
|
||||
*/
|
||||
private function viewHeaders(string $view, array $range, string $tz, int $weekStart): array
|
||||
{
|
||||
$start = $range['start']->copy()->tz($tz);
|
||||
$end = $range['end']->copy()->tz($tz);
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
// month view: weekday headers (Sunday..Saturday for now)
|
||||
if ($view === 'month') {
|
||||
return collect($this->weekdayHeaders($tz, $weekStart))
|
||||
->map(fn ($h) => $h + ['is_today' => false])
|
||||
->all();
|
||||
}
|
||||
|
||||
// day/week/four: column headers for each day in range
|
||||
$headers = [];
|
||||
for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) {
|
||||
$date = $d->toDateString();
|
||||
$headers[] = [
|
||||
'date' => $d->toDateString(), // 2026-01-31
|
||||
'day' => $d->format('j'), // 31
|
||||
'dow' => $d->translatedFormat('l'), // Saturday (localized)
|
||||
'dow_short' => $d->translatedFormat('D'), // Sat (localized)
|
||||
'month' => $d->translatedFormat('M'), // Jan (localized)
|
||||
'is_today' => $date === $today, // flag for viewing real today
|
||||
];
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Specific headers for month views (full and mini)
|
||||
*/
|
||||
private function weekdayHeaders(string $tz, int $weekStart): array
|
||||
{
|
||||
$headers = [];
|
||||
$d = Carbon::now($tz)->startOfWeek($weekStart);
|
||||
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$headers[] = [
|
||||
// stable key (0..6 from the start day)
|
||||
'key' => $i,
|
||||
// Sun, Mon...
|
||||
'label' => $d->translatedFormat('D'),
|
||||
];
|
||||
$d->addDay();
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Build the mini-month grid for day buttons in the bottom left of the UI
|
||||
*
|
||||
* Returns ['days' => [
|
||||
* [
|
||||
@ -525,34 +864,36 @@ class CalendarController extends Controller
|
||||
* ], …
|
||||
* ]]
|
||||
*/
|
||||
private function buildMiniGrid(Carbon $monthStart, Collection $events): array
|
||||
private function buildMiniGrid(
|
||||
Carbon $monthStart,
|
||||
Collection $events,
|
||||
string $tz,
|
||||
int $weekStart,
|
||||
int $weekEnd): array
|
||||
{
|
||||
// get bounds
|
||||
$monthStart = $monthStart->copy()->tz($tz);
|
||||
$monthEnd = $monthStart->copy()->endOfMonth();
|
||||
$gridStart = $monthStart->copy()->startOfWeek(Carbon::MONDAY);
|
||||
$gridEnd = $monthEnd->copy()->endOfWeek(Carbon::SUNDAY);
|
||||
|
||||
// ensure we have 42 days (6 rows); 35 = add one extra week
|
||||
$gridStart = $monthStart->copy()->startOfWeek($weekStart);
|
||||
$gridEnd = $monthEnd->copy()->endOfWeek($weekEnd);
|
||||
|
||||
if ($gridStart->diffInDays($gridEnd) + 1 < 42) {
|
||||
$gridEnd->addWeek();
|
||||
}
|
||||
|
||||
/* map event-ids by yyyy-mm-dd */
|
||||
$today = Carbon::today($tz)->toDateString();
|
||||
|
||||
// map event ids by yyyy-mm-dd in USER tz (so indicators match what user sees)
|
||||
$byDay = [];
|
||||
$tzFallback = auth()->user()->timezone ?? config('app.timezone', 'UTC');
|
||||
|
||||
foreach ($events as $ev) {
|
||||
$evTz = $ev['timezone'] ?? $tzFallback;
|
||||
|
||||
$s = Carbon::parse($ev['start'])->tz($evTz);
|
||||
$e = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $s;
|
||||
$s = Carbon::parse($ev['start'])->tz($tz);
|
||||
$e = $ev['end'] ? Carbon::parse($ev['end'])->tz($tz) : $s;
|
||||
|
||||
for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) {
|
||||
$byDay[$d->toDateString()][] = $ev['id'];
|
||||
}
|
||||
}
|
||||
|
||||
/* Walk the 42-day span */
|
||||
$days = [];
|
||||
for ($d = $gridStart->copy(); $d->lte($gridEnd); $d->addDay()) {
|
||||
$iso = $d->toDateString();
|
||||
@ -560,11 +901,64 @@ class CalendarController extends Controller
|
||||
'date' => $iso,
|
||||
'label' => $d->format('j'),
|
||||
'in_month' => $d->between($monthStart, $monthEnd),
|
||||
'is_today' => $iso === $today,
|
||||
'events' => $byDay[$iso] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
// will always be 42 to ensure 6 rows
|
||||
return ['days' => $days];
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the "now" indicator on the grid if we're in a time-based view and on the current day
|
||||
*
|
||||
* Returns:
|
||||
* [
|
||||
* 'show' => bool,
|
||||
* 'row' => int, // 1..96 (15-min grid line)
|
||||
* 'col_start' => int, // grid column start
|
||||
* 'col_end' => int, // grid column end
|
||||
* ]
|
||||
*/
|
||||
private function nowIndicator(string $view, array $range, string $tz, int $minutesPerSlot = 15, int $gutterCols = 1): array
|
||||
{
|
||||
// only meaningful for time-based views
|
||||
if (!in_array($view, ['day', 'week', 'four'], true)) {
|
||||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
||||
}
|
||||
|
||||
$now = Carbon::now($tz);
|
||||
|
||||
$start = $range['start']->copy()->tz($tz)->startOfDay();
|
||||
$end = $range['end']->copy()->tz($tz)->endOfDay();
|
||||
|
||||
// show only if "now" is inside the visible range
|
||||
if (! $now->betweenIncluded($start, $end)) {
|
||||
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
|
||||
}
|
||||
|
||||
// row: minutes since midnight, snapped down to slot size
|
||||
$minutes = ($now->hour * 60) + $now->minute;
|
||||
$snapped = intdiv($minutes, $minutesPerSlot) * $minutesPerSlot;
|
||||
$row = intdiv($snapped, $minutesPerSlot) + 1; // 1-based
|
||||
|
||||
// column: 1..N where 1 is the first day column in the events grid
|
||||
if ($view === 'day') {
|
||||
$dayCol = 1;
|
||||
} else {
|
||||
// IMPORTANT: compare dates at midnight to avoid fractional diffs
|
||||
$todayStart = $now->copy()->startOfDay();
|
||||
$dayCol = $start->diffInDays($todayStart) + 1; // 1..7 or 1..4
|
||||
}
|
||||
|
||||
$dayCol = (int) $dayCol;
|
||||
|
||||
return [
|
||||
'show' => true,
|
||||
'row' => (int) $row,
|
||||
'day_col' => $dayCol,
|
||||
'col_start' => $dayCol,
|
||||
'col_end' => $dayCol + 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ class CalendarSettingsController extends Controller
|
||||
/* landing page shows the first settings pane */
|
||||
public function index()
|
||||
{
|
||||
return redirect()->route('calendar.settings.create');
|
||||
return redirect()->route('calendar.settings.display');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -66,12 +66,46 @@ class CalendarSettingsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Display preferences
|
||||
*/
|
||||
public function displayForm(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$data = [
|
||||
'title' => __('calendar.settings.display.title'),
|
||||
'sub' => __('calendar.settings.display.subtitle'),
|
||||
'defaults' => [
|
||||
// store as: 'sunday' | 'monday'
|
||||
'week_start' => $user->getSetting('calendar.week_start', 'monday'),
|
||||
],
|
||||
];
|
||||
|
||||
return $this->frame('calendar.settings.display', $data);
|
||||
}
|
||||
|
||||
public function displayStore(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'week_start' => ['required', 'in:sunday,monday'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
$user->setSetting('calendar.week_start', $data['week_start']);
|
||||
|
||||
return Redirect::route('calendar.settings.display')
|
||||
->with('toast', [
|
||||
'message' => __('calendar.settings.saved'),
|
||||
'type' => 'success',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Subscribe
|
||||
**/
|
||||
|
||||
/* show “Subscribe to a calendar” form */
|
||||
*/
|
||||
public function subscribeForm()
|
||||
{
|
||||
return $this->frame(
|
||||
@ -82,7 +116,6 @@ class CalendarSettingsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/* handle POST from the subscribe form */
|
||||
public function subscribeStore(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
@ -109,7 +142,8 @@ class CalendarSettingsController extends Controller
|
||||
|
||||
|
||||
/**
|
||||
* individual calendar settings
|
||||
*
|
||||
* Individual calendar settings
|
||||
*/
|
||||
public function calendarForm(Request $request, string $calendarUri)
|
||||
{
|
||||
@ -199,7 +233,8 @@ class CalendarSettingsController extends Controller
|
||||
|
||||
|
||||
/**
|
||||
* content frame handler
|
||||
*
|
||||
* Content frame handler
|
||||
*/
|
||||
private function frame(?string $view = null, array $data = [])
|
||||
{
|
||||
|
||||
@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class Calendar extends Model
|
||||
@ -50,6 +51,29 @@ class Calendar extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* build all calendar data for calendar display
|
||||
*/
|
||||
public function scopeDashboardForPrincipal(Builder $q, string $principal): Builder
|
||||
{
|
||||
return $q->select(
|
||||
'calendars.id',
|
||||
'ci.displayname',
|
||||
'ci.calendarcolor',
|
||||
'ci.uri as slug',
|
||||
'ci.timezone as timezone',
|
||||
'meta.color as meta_color',
|
||||
'meta.color_fg as meta_color_fg',
|
||||
DB::raw('COALESCE(meta.is_remote, 0) as is_remote')
|
||||
)
|
||||
->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
|
||||
->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
|
||||
->where('ci.principaluri', $principal)
|
||||
->orderBy('ci.displayname');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* inbound urls
|
||||
* convert "/calendar/{slug}" into the correct calendar instance (uri column)
|
||||
*
|
||||
|
||||
@ -30,6 +30,10 @@ return [
|
||||
'title' => 'Create a calendar',
|
||||
'subtitle' => 'Create a new local calendar.',
|
||||
],
|
||||
'display' => [
|
||||
'title' => 'Display preferences',
|
||||
'subtitle' => 'Adjust the look and feel of your calendars.'
|
||||
],
|
||||
'language_region' => [
|
||||
'title' => 'Language and region',
|
||||
'subtitle' => 'Choose your default language, region, and formatting preferences. These affect how dates and times are displayed in your calendars and events.',
|
||||
|
||||
@ -118,7 +118,7 @@ main {
|
||||
|
||||
/* left column */
|
||||
aside {
|
||||
@apply flex flex-col col-span-1 pb-8 h-16 overflow-hidden rounded-l-lg;
|
||||
@apply flex flex-col col-span-1 pb-6 2xl:pb-8 h-16 overflow-hidden rounded-l-lg;
|
||||
|
||||
> h1 {
|
||||
@apply flex items-center h-16 min-h-16 px-6 2xl:px-8;
|
||||
@ -185,21 +185,22 @@ main {
|
||||
@apply bg-white grid grid-cols-1 ml-2 rounded-md;
|
||||
@apply overflow-y-auto;
|
||||
grid-template-rows: 5rem auto;
|
||||
container: content / inline-size;
|
||||
|
||||
/* main content title and actions */
|
||||
> header {
|
||||
@apply flex flex-row items-center justify-between w-full;
|
||||
@apply bg-white sticky top-0;
|
||||
@apply bg-white sticky top-0 z-10;
|
||||
|
||||
/* app hedar; if h1 exists it means there's no aside, so force the width from that */
|
||||
h1 {
|
||||
@apply relative flex items-center pl-6 2xl:pl-8;
|
||||
width: minmax(20rem, 20dvw);
|
||||
width: minmax(16rem, 20dvw);
|
||||
}
|
||||
|
||||
/* actual page header */
|
||||
h2 {
|
||||
@apply flex flex-row gap-1 items-center justify-start relative top-2px;
|
||||
@apply flex flex-row gap-2 items-center justify-start relative top-2px;
|
||||
animation: title-drop 350ms ease-out both;
|
||||
|
||||
> span {
|
||||
@ -309,7 +310,7 @@ main {
|
||||
|
||||
main {
|
||||
&:has(aside) {
|
||||
grid-template-columns: minmax(20rem, 20dvw) auto;
|
||||
grid-template-columns: minmax(16rem, 20dvw) auto;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
--font-serif: Chewie, ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
--color-gray-10: #fafafa;
|
||||
--color-gray-50: #f6f6f6;
|
||||
--color-gray-100: #eeeeee;
|
||||
--color-gray-200: #dddddd;
|
||||
|
||||
@ -101,7 +101,7 @@ button,
|
||||
|
||||
&:has(input:checked) {}
|
||||
|
||||
&:first-child {
|
||||
&:first-of-type {
|
||||
@apply rounded-l-md border-l-md;
|
||||
|
||||
&:has(input:checked),
|
||||
@ -119,7 +119,7 @@ button,
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
&:last-of-type {
|
||||
@apply border-r-md rounded-r-md;
|
||||
|
||||
&:has(input:checked),
|
||||
@ -129,18 +129,19 @@ button,
|
||||
}
|
||||
}
|
||||
|
||||
button:active + button {
|
||||
|
||||
}
|
||||
|
||||
> label {
|
||||
> input[type="radio"] {
|
||||
@apply hidden absolute top-0 left-0 w-0 h-0 max-w-0 max-h-0;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> :last-child input:checked),
|
||||
&:has(> :last-child:active) {
|
||||
&:has(> label:last-of-type input:checked),
|
||||
&:has(> label:last-of-type:active) {
|
||||
box-shadow: 1.5px 4.5px 0 -2px var(--color-primary);
|
||||
}
|
||||
|
||||
/* small */
|
||||
&.button-group--sm {
|
||||
@apply h-9 max-h-9 text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
.calendar {
|
||||
/**
|
||||
* month view
|
||||
**/
|
||||
.calendar.month {
|
||||
@apply grid col-span-3 pb-6 2xl:pb-8 pt-2;
|
||||
grid-template-rows: 2rem 1fr;
|
||||
|
||||
@ -73,8 +76,245 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* calendar list in the left bar */
|
||||
/**
|
||||
* time-based views
|
||||
*/
|
||||
.calendar.time {
|
||||
@apply grid;
|
||||
grid-template-columns: 6rem auto;
|
||||
grid-template-rows: 4.5rem auto 5rem;
|
||||
--row-height: 2.5rem;
|
||||
--now-row: 1;
|
||||
--now-col-start: 1;
|
||||
--now-col-end: 2;
|
||||
|
||||
/* top day bar */
|
||||
hgroup {
|
||||
@apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10;
|
||||
top: 5.5rem;
|
||||
|
||||
span.name {
|
||||
@apply font-semibold uppercase text-sm;
|
||||
}
|
||||
|
||||
a.number {
|
||||
@apply flex items-center justify-center text-xl h-10 w-10 rounded-full bg-gray-100 font-semibold;
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
div.day-header {
|
||||
@apply relative flex flex-col gap-2px justify-start items-start;
|
||||
animation: header-slide 250ms ease-in;
|
||||
|
||||
&:not(:last-of-type)::after {
|
||||
@apply block w-px bg-gray-200 absolute -right-2 top-18;
|
||||
content: '';
|
||||
height: calc(100dvh - 16rem);
|
||||
}
|
||||
|
||||
&.active {
|
||||
a.number {
|
||||
@apply bg-teal-500 text-white;
|
||||
|
||||
&:hover {
|
||||
@apply bg-teal-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* time column */
|
||||
ol.time {
|
||||
@apply grid z-0 pt-4;
|
||||
grid-template-rows: repeat(96, var(--row-height));
|
||||
|
||||
time {
|
||||
@apply relative flex items-center justify-end items-start pr-4;
|
||||
@apply text-xs text-secondary font-mono;
|
||||
|
||||
&::after {
|
||||
@apply block absolute h-px bg-gray-200;
|
||||
width: calc(100cqw - 6rem);
|
||||
content: '';
|
||||
top: 0.6rem;
|
||||
left: 6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* event positioning */
|
||||
ol.events {
|
||||
@apply grid pt-4;
|
||||
grid-template-rows: repeat(96, var(--row-height));
|
||||
--event-col: 0;
|
||||
--event-row: 0;
|
||||
--event-end: 4;
|
||||
--event-bg: var(--color-gray-100);
|
||||
--event-fg: var(--color-primary);
|
||||
|
||||
li.event {
|
||||
@apply flex rounded-md relative;
|
||||
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);
|
||||
top: 0.6rem;
|
||||
transition: translate 100ms ease-in;
|
||||
|
||||
> a {
|
||||
@apply flex flex-col grow px-3 py-2 gap-2px;
|
||||
|
||||
> span {
|
||||
@apply font-semibold leading-none break-all;
|
||||
}
|
||||
|
||||
> time {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply -translate-y-2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* bottom controls */
|
||||
footer {
|
||||
@apply bg-white flex items-end justify-end col-span-2 border-t-md border-primary z-10;
|
||||
@apply sticky bottom-0 pb-8;
|
||||
}
|
||||
|
||||
/* now indicator */
|
||||
.now-indicator {
|
||||
@apply relative pointer-events-none z-2 border-t-3 border-red-600 opacity-90 -ml-2;
|
||||
grid-row: var(--now-row);
|
||||
grid-column: var(--now-col-start) / var(--now-col-end);
|
||||
width: calc(100% + 1rem);
|
||||
top: 0.6rem;
|
||||
|
||||
&::before {
|
||||
@apply block w-3 h-3 rounded-full bg-red-600 -translate-y-1/2 -mt-[1.5px];
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* step handling */
|
||||
.calendar.time[data-density="30"] {
|
||||
--row-height: 2rem;
|
||||
|
||||
ol.time li:nth-child(2n) {
|
||||
visibility: hidden; /* preserves space + row alignment */
|
||||
}
|
||||
}
|
||||
.calendar.time[data-density="60"] {
|
||||
--row-height: 1.25rem;
|
||||
|
||||
ol.time li:not(:nth-child(4n + 1)) {
|
||||
visibility: hidden; /* preserves space + row alignment */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* day view
|
||||
*/
|
||||
.calendar.day {
|
||||
container: day / inline-size;
|
||||
|
||||
hgroup {
|
||||
@apply flex items-start justify-start;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* week view
|
||||
*/
|
||||
.calendar.week {
|
||||
container: week / inline-size;
|
||||
--days: 7;
|
||||
|
||||
hgroup {
|
||||
@apply grid gap-x-2;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
ol.events {
|
||||
@apply gap-x-2;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
--col: calc(100% / var(--days));
|
||||
|
||||
/* draw a 1px line at the start of each column repeat + highlight weekends */
|
||||
/* need to factor in weekends on/off */
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
var(--color-gray-10) var(--col),
|
||||
transparent var(--col),
|
||||
transparent calc((var(--col) * 6) + 5px),
|
||||
var(--color-gray-10) calc((var(--col) * 6) + 5px)
|
||||
);
|
||||
background-position: 0;
|
||||
background-size: 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
&[data-weekstart="0"] {
|
||||
ol.events {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
transparent calc((var(--col) * 5) + 5px),
|
||||
var(--color-gray-10) calc((var(--col) * 5) + 5px)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* four-day view
|
||||
*/
|
||||
.calendar.four {
|
||||
container: four / inline-size;
|
||||
--days: 4;
|
||||
|
||||
hgroup {
|
||||
@apply grid gap-x-2;
|
||||
grid-template-columns: repeat(var(--days), 1fr);
|
||||
}
|
||||
|
||||
ol.events {
|
||||
@apply gap-x-2;
|
||||
grid-template-columns: repeat(var(--days), 1fr);
|
||||
--col: calc(100% / var(--days));
|
||||
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
transparent var(--col),
|
||||
var(--color-gray-200) var(--col),
|
||||
var(--color-gray-200) calc(var(--col) + 1px)
|
||||
);
|
||||
background-position: 0;
|
||||
background-size: 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* calendar list in the left bar
|
||||
**/
|
||||
#calendar-toggles {
|
||||
@apply pb-6;
|
||||
|
||||
summary {
|
||||
@apply flex items-center gap-1 justify-start;
|
||||
@ -93,9 +333,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li.calendar-toggle {
|
||||
li.calendar-toggle {
|
||||
@apply relative;
|
||||
|
||||
/* hide the edit link by default */
|
||||
@ -115,9 +354,12 @@ li.calendar-toggle {
|
||||
.checkbox-label span {
|
||||
@apply line-clamp-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* animations */
|
||||
/**
|
||||
* animations
|
||||
**/
|
||||
@keyframes event-slide {
|
||||
from {
|
||||
opacity: 0;
|
||||
@ -129,3 +371,14 @@ li.calendar-toggle {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes header-slide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,18 @@ document.addEventListener('change', event => {
|
||||
.forEach(el => el.classList.toggle('hidden', !show));
|
||||
});
|
||||
|
||||
/**
|
||||
* calendar view picker
|
||||
* progressive enhancement on html form with no js
|
||||
*/
|
||||
document.addEventListener('change', (e) => {
|
||||
const form = e.target?.form;
|
||||
if (!form || form.id !== 'calendar-view') return;
|
||||
if (e.target.name !== 'view') return;
|
||||
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
/**
|
||||
* color picker component
|
||||
* native <input type="color"> + hex + random palette)
|
||||
|
||||
1
resources/svg/icons/preferences.svg
Normal file
1
resources/svg/icons/preferences.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 17H5"/><path d="M19 7h-9"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
|
||||
|
After Width: | Height: | Size: 288 B |
1
resources/svg/icons/solid/preferences.svg
Normal file
1
resources/svg/icons/solid/preferences.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 17H5"/><path d="M19 7h-9"/><circle cx="17" cy="17" r="3" fill="currentColor"/><circle cx="7" cy="7" r="3" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@ -68,15 +68,29 @@
|
||||
</details>
|
||||
</form>
|
||||
</div>
|
||||
<x-calendar.mini :mini="$mini" :nav="$mini_nav" :view="$view" class="aside-inset" />
|
||||
<x-calendar.mini
|
||||
:mini="$mini"
|
||||
:nav="$mini_nav"
|
||||
:view="$view"
|
||||
:density="$density"
|
||||
:headers="$mini_headers"
|
||||
class="aside-inset"
|
||||
/>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="header">
|
||||
<h2>
|
||||
<strong>{{ $active['month'] }}</strong>
|
||||
<span>{{ $active['year'] }}</span>
|
||||
<strong>{{ $header['strong'] }}</strong>
|
||||
@if(!empty($header['span']))
|
||||
<span>{{ $header['span'] }}</span>
|
||||
@endif
|
||||
</h2>
|
||||
<menu>
|
||||
<li>
|
||||
<a class="button button--icon" href="{{ route('calendar.settings') }}">
|
||||
<x-icon-settings />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<form id="calendar-nav"
|
||||
action="{{ route('calendar.index') }}"
|
||||
@ -88,16 +102,30 @@
|
||||
hx-push-url="true"
|
||||
hx-include="#calendar-toggles">
|
||||
|
||||
{{-- keep current view (month/week/4day) --}}
|
||||
{{-- persist values from other forms --}}
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="density" value="{{ $density['step'] }}">
|
||||
|
||||
<nav class="button-group button-group--primary">
|
||||
<x-button.group-button type="submit" name="date" value="{{ $nav['prev'] }}" aria-label="Go back 1 month">
|
||||
<x-button.group-button
|
||||
type="submit"
|
||||
name="date"
|
||||
value="{{ $nav['prev'] }}"
|
||||
aria-label="Go back 1 month">
|
||||
<x-icon-chevron-left />
|
||||
</x-button.group-button>
|
||||
<x-button.group-button type="submit" name="date" value="{{ $nav['today'] }}" aria-label="Go to today">
|
||||
<x-button.group-button
|
||||
type="submit"
|
||||
name="date"
|
||||
value="{{ $nav['today'] }}"
|
||||
aria-label="Go to today">
|
||||
Today
|
||||
</x-button.group-button>
|
||||
<x-button.group-button type="submit" name="date" value="{{ $nav['next'] }}" aria-label="Go forward 1 month">
|
||||
<x-button.group-button
|
||||
type="submit"
|
||||
name="date"
|
||||
value="{{ $nav['next'] }}"
|
||||
aria-label="Go forward 1 month">
|
||||
<x-icon-chevron-right />
|
||||
</x-button.group-button>
|
||||
</nav>
|
||||
@ -107,27 +135,88 @@
|
||||
</form>
|
||||
</li>
|
||||
<li>
|
||||
<form id="calendar-view" class="button-group button-group--primary" method="get" action="/">
|
||||
<x-button.group-input>Day</x-button.group-button>
|
||||
<x-button.group-input>Week</x-button.group-button>
|
||||
<x-button.group-input active="true">Month</x-button.group-button>
|
||||
<x-button.group-input>3-Up</x-button.group-button>
|
||||
<form id="calendar-view"
|
||||
class="button-group button-group--primary"
|
||||
method="get"
|
||||
action="{{ route('calendar.index') }}"
|
||||
hx-get="{{ route('calendar.index') }}"
|
||||
hx-target="#calendar"
|
||||
hx-select="#calendar"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-include="#calendar-toggles">
|
||||
|
||||
{{-- persist data from density form --}}
|
||||
<input type="hidden" name="density" value="{{ $density['step'] }}">
|
||||
<x-button.group-input value="day" :active="$view === 'day'">Day</x-button.group-input>
|
||||
<x-button.group-input value="week" :active="$view === 'week'">Week</x-button.group-input>
|
||||
<x-button.group-input value="month" :active="$view === 'month'">Month</x-button.group-input>
|
||||
<x-button.group-input value="four" :active="$view === 'four'">3-Up</x-button.group-input>
|
||||
<noscript><button type="submit" class="button">Apply</button></noscript>
|
||||
</form>
|
||||
<li>
|
||||
<a class="button button--primary" href="{{ route('calendar.create') }}">
|
||||
<x-icon-plus-circle /> Create
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="button button--icon" href="{{ route('calendar.settings') }}">
|
||||
<x-icon-settings />
|
||||
</a>
|
||||
</li>
|
||||
</menu>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="article">
|
||||
<x-calendar.full class="month" :grid="$grid" :calendars="$calendars" :events="$events" />
|
||||
@switch($view)
|
||||
@case('week')
|
||||
<x-calendar.week
|
||||
class="week time"
|
||||
:grid="$grid"
|
||||
:calendars="$calendars"
|
||||
:events="$events"
|
||||
:slots="$slots"
|
||||
:timeformat="$time_format"
|
||||
:hgroup="$hgroup"
|
||||
:active="$active"
|
||||
:density="$density"
|
||||
:weekstart="$week_start"
|
||||
:now="$now"
|
||||
/>
|
||||
@break
|
||||
@case('day')
|
||||
<x-calendar.day
|
||||
class="day time"
|
||||
:grid="$grid"
|
||||
:calendars="$calendars"
|
||||
:events="$events"
|
||||
:slots="$slots"
|
||||
:timeformat="$time_format"
|
||||
:hgroup="$hgroup"
|
||||
:active="$active"
|
||||
:density="$density"
|
||||
:now="$now"
|
||||
/>
|
||||
@break
|
||||
@case('four')
|
||||
<x-calendar.four
|
||||
class="four time"
|
||||
:grid="$grid"
|
||||
:calendars="$calendars"
|
||||
:events="$events"
|
||||
:slots="$slots"
|
||||
:timeformat="$time_format"
|
||||
:hgroup="$hgroup"
|
||||
:active="$active"
|
||||
:density="$density"
|
||||
:now="$now"
|
||||
/>
|
||||
@break
|
||||
@default
|
||||
<x-calendar.month
|
||||
class="month"
|
||||
:grid="$grid"
|
||||
:calendars="$calendars"
|
||||
:events="$events"
|
||||
:hgroup="$hgroup"
|
||||
:active="$active"
|
||||
/>
|
||||
@endswitch
|
||||
</x-slot>
|
||||
|
||||
</x-app-layout>
|
||||
|
||||
29
resources/views/calendar/settings/display.blade.php
Normal file
29
resources/views/calendar/settings/display.blade.php
Normal file
@ -0,0 +1,29 @@
|
||||
@php($defaults = $data['defaults'] ?? [])
|
||||
@php($weekStart = $defaults['week_start'] ?? 'monday')
|
||||
|
||||
<div class="description">
|
||||
<p>
|
||||
{{ $data['sub'] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('calendar.settings.display.store') }}" class="settings">
|
||||
@csrf
|
||||
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<x-input.label for="name" :value="__('Start of the week')" />
|
||||
<div class="button-group button-group--primary self-start">
|
||||
<x-button.group-input value="sunday" name="week_start" :active="$weekStart === 'sunday'">{{ __('Sunday') }}</x-button.group-input>
|
||||
<x-button.group-input value="monday" name="week_start" :active="$weekStart === 'monday'">{{ __('Monday') }}</x-button.group-input>
|
||||
</div>
|
||||
<x-input.error :messages="$errors->get('week_start')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row input-row--actions input-row--start sticky-bottom">
|
||||
<x-button variant="primary" type="submit">{{ __('common.save_changes') }}</x-button>
|
||||
<x-button type="anchor" variant="tertiary" href="{{ route('calendar.settings.display') }}">{{ __('common.cancel') }}</x-button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@ -1,10 +1,24 @@
|
||||
@props([
|
||||
'name' => 'button-group',
|
||||
'value' => '1',
|
||||
'name' => 'view', // default to view, since it's mostly used for that
|
||||
'value' => '',
|
||||
'class' => '',
|
||||
'active' => false ])
|
||||
'active' => false,
|
||||
'id' => null,
|
||||
'disabled' => false,
|
||||
])
|
||||
|
||||
<label class="{{ $class }}">
|
||||
<input type="radio" name="{{ $name }}" value="{{ $value }}" @checked($active)>
|
||||
@php
|
||||
$id = $id ?: $name.'_'.$value;
|
||||
@endphp
|
||||
|
||||
<label {{ $attributes->merge(['class' => trim($class)]) }}>
|
||||
<input
|
||||
id="{{ $id }}"
|
||||
type="radio"
|
||||
name="{{ $name }}"
|
||||
value="{{ $value }}"
|
||||
@checked($active)
|
||||
@disabled($disabled)
|
||||
>
|
||||
{{ $slot }}
|
||||
</label>
|
||||
|
||||
44
resources/views/components/calendar/day/day.blade.php
Normal file
44
resources/views/components/calendar/day/day.blade.php
Normal file
@ -0,0 +1,44 @@
|
||||
@props([
|
||||
'grid' => [],
|
||||
'calendars' => [],
|
||||
'events' => [],
|
||||
'class' => '',
|
||||
'slots' => [],
|
||||
'timeformat' => '',
|
||||
'hgroup' => [],
|
||||
'active' => [],
|
||||
'density' => '30',
|
||||
'now' => [],
|
||||
])
|
||||
|
||||
<section
|
||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
||||
style="--now-row: {{ $now['row'] }}; --now-col-start: {{ $now['col_start'] }}; --now-col-end: {{ $now['col_end'] }};"
|
||||
>
|
||||
<hgroup>
|
||||
@foreach ($hgroup as $h)
|
||||
<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>
|
||||
</div>
|
||||
@endforeach
|
||||
</hgroup>
|
||||
<ol class="time" aria-label="{{ __('Times') }}">
|
||||
@foreach ($slots as $slot)
|
||||
<li>
|
||||
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
<ol class="events" aria-label="{{ __('Events') }}">
|
||||
@foreach ($events as $event)
|
||||
<x-calendar.time.event :event="$event" />
|
||||
@endforeach
|
||||
@if ($now['show'])
|
||||
<li class="now-indicator" aria-hidden="true"></li>
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<x-calendar.time.density view="day" :density="$density" />
|
||||
</footer>
|
||||
</section>
|
||||
47
resources/views/components/calendar/four/four.blade.php
Normal file
47
resources/views/components/calendar/four/four.blade.php
Normal file
@ -0,0 +1,47 @@
|
||||
@props([
|
||||
'grid' => [],
|
||||
'calendars' => [],
|
||||
'events' => [],
|
||||
'class' => '',
|
||||
'slots' => [],
|
||||
'timeformat' => '',
|
||||
'hgroup' => [],
|
||||
'active' => [],
|
||||
'density' => '30',
|
||||
'now' => [],
|
||||
])
|
||||
|
||||
<section
|
||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
||||
style=
|
||||
"--now-row: {{ (int) $now['row'] }};
|
||||
--now-col-start: {{ (int) $now['col_start'] }};
|
||||
--now-col-end: {{ (int) $now['col_end'] }};"
|
||||
>
|
||||
<hgroup>
|
||||
@foreach ($hgroup as $h)
|
||||
<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>
|
||||
</div>
|
||||
@endforeach
|
||||
</hgroup>
|
||||
<ol class="time" aria-label="{{ __('Times') }}">
|
||||
@foreach ($slots as $slot)
|
||||
<li>
|
||||
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
<ol class="events" aria-label="{{ __('Events') }}">
|
||||
@foreach ($events as $event)
|
||||
<x-calendar.time.event :event="$event" />
|
||||
@endforeach
|
||||
@if ($now['show'])
|
||||
<li class="now-indicator" aria-hidden="true"></li>
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<x-calendar.time.density view="four" :density="$density" />
|
||||
</footer>
|
||||
</section>
|
||||
@ -1,4 +1,11 @@
|
||||
@props(['mini', 'view', 'nav', 'class' => ''])
|
||||
@props([
|
||||
'mini',
|
||||
'view',
|
||||
'nav',
|
||||
'class' => '',
|
||||
'density' => [],
|
||||
'headers' => [],
|
||||
])
|
||||
|
||||
<section id="mini" class="mini mini--month {{ $class }}">
|
||||
<header>
|
||||
@ -27,13 +34,9 @@
|
||||
</header>
|
||||
<nav>
|
||||
<hgroup>
|
||||
<span>Mo</span>
|
||||
<span>Tu</span>
|
||||
<span>We</span>
|
||||
<span>Th</span>
|
||||
<span>Fr</span>
|
||||
<span>Sa</span>
|
||||
<span>Su</span>
|
||||
@foreach ($headers as $h)
|
||||
<span class="capitalize">{{ substr($h['label'], 0, 2) }}</span>
|
||||
@endforeach
|
||||
</hgroup>
|
||||
{{-- form drives the main calendar --}}
|
||||
<form action="{{ route('calendar.index') }}"
|
||||
@ -45,8 +48,9 @@
|
||||
hx-push-url="true"
|
||||
hx-include="#calendar-toggles">
|
||||
|
||||
{{-- stay on the same view (month / week…) --}}
|
||||
{{-- stay on the same view (month/week/etc --}}
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="density" value="{{ $density['step'] }}">
|
||||
|
||||
@foreach ($mini['days'] as $day)
|
||||
<button
|
||||
@ -57,7 +61,7 @@
|
||||
class="day
|
||||
{{ count($day['events']) ? 'day--with-events' : '' }}
|
||||
{{ $day['in_month'] ? '' : 'day--outside' }}
|
||||
{{ $day['date'] === today()->toDateString() ? 'day--today' : '' }}">
|
||||
{{ $day['is_today'] ? 'day--today' : '' }}">
|
||||
{{ $day['label'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
@ -27,7 +27,8 @@
|
||||
hx-push-url="false"
|
||||
hx-swap="innerHTML"
|
||||
style="--event-color: {{ $color }}"
|
||||
data-calendar="{{ $event['calendar_slug'] }}">
|
||||
data-calendar="{{ $event['calendar_slug'] }}"
|
||||
>
|
||||
<i class="indicator" aria-label="Calendar indicator"></i>
|
||||
<span class="title">{{ $event['title'] }}</span>
|
||||
<time>{{ $event['start_ui'] }}</time>
|
||||
@ -2,23 +2,20 @@
|
||||
'grid' => ['weeks' => []],
|
||||
'calendars' => [],
|
||||
'events' => [],
|
||||
'class' => ''
|
||||
'class' => '',
|
||||
'hgroup' => [],
|
||||
])
|
||||
|
||||
<section class="calendar {{ $class }}">
|
||||
<hgroup>
|
||||
<span>Mon</span>
|
||||
<span>Tue</span>
|
||||
<span>Wed</span>
|
||||
<span>Thu</span>
|
||||
<span>Fri</span>
|
||||
<span>Sat</span>
|
||||
<span>Sun</span>
|
||||
@foreach ($hgroup as $h)
|
||||
<span>{{ $h['label'] }}</span>
|
||||
@endforeach
|
||||
</hgroup>
|
||||
<ol data-weeks="{{ count($grid['weeks']) }}">
|
||||
@foreach ($grid['weeks'] as $week)
|
||||
@foreach ($week as $day)
|
||||
<x-calendar.day :day="$day" :events="$events" :calendars="$calendars" />
|
||||
<x-calendar.month.day :day="$day" :events="$events" :calendars="$calendars" />
|
||||
@endforeach
|
||||
@endforeach
|
||||
</ol>
|
||||
29
resources/views/components/calendar/time/density.blade.php
Normal file
29
resources/views/components/calendar/time/density.blade.php
Normal file
@ -0,0 +1,29 @@
|
||||
@props([
|
||||
'density' => [],
|
||||
'view' => 'day',
|
||||
])
|
||||
|
||||
<form id="calendar-density"
|
||||
method="get"
|
||||
class="button-group button-group--primary button-group--sm"
|
||||
action="{{ route('calendar.index') }}"
|
||||
hx-get="{{ route('calendar.index') }}"
|
||||
hx-target="#calendar"
|
||||
hx-select="#calendar"
|
||||
hx-swap="outerHTML"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change"
|
||||
hx-include="#calendar-toggles">
|
||||
|
||||
{{-- preserve current view and anchor date --}}
|
||||
<input type="hidden" name="view" value="{{ $view }}">
|
||||
<input type="hidden" name="date" value="{{ $density['anchor'] }}">
|
||||
|
||||
<x-button.group-input value="15" name="density" :active="(int)($density['step'] ?? 30) === 15">15m</x-button.group-input>
|
||||
<x-button.group-input value="30" name="density" :active="(int)($density['step'] ?? 30) === 30">30m</x-button.group-input>
|
||||
<x-button.group-input value="60" name="density" :active="(int)($density['step'] ?? 30) === 60">Hour</x-button.group-input>
|
||||
|
||||
<noscript>
|
||||
<button type="submit" class="button">{{ __('Apply') }}</button>
|
||||
</noscript>
|
||||
</form>
|
||||
28
resources/views/components/calendar/time/event.blade.php
Normal file
28
resources/views/components/calendar/time/event.blade.php
Normal file
@ -0,0 +1,28 @@
|
||||
@props([
|
||||
'event' => [],
|
||||
])
|
||||
|
||||
<li class="event"
|
||||
data-event-id="{{ $event['id'] }}"
|
||||
data-calendar-id="{{ $event['calendar_slug'] }}"
|
||||
data-start="{{ $event['start_ui'] }}"
|
||||
data-duration="{{ $event['duration'] }}"
|
||||
style="
|
||||
--event-row: {{ $event['start_row'] }};
|
||||
--event-end: {{ $event['end_row'] }};
|
||||
--event-col: {{ $event['start_col'] }};
|
||||
--event-bg: {{ $event['color'] }};
|
||||
--event-fg: {{ $event['color_fg'] }};
|
||||
">
|
||||
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
|
||||
href="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
hx-target="#modal"
|
||||
hx-push-url="false"
|
||||
hx-swap="innerHTML"
|
||||
data-calendar="{{ $event['calendar_slug'] }}"
|
||||
>
|
||||
<span>{{ $event['title'] }}</span>
|
||||
<time datetime="{{ $event['start'] }}">{{ $event['start_ui'] }} - {{ $event['end_ui'] }}</time>
|
||||
</a>
|
||||
</li>
|
||||
48
resources/views/components/calendar/week/week.blade.php
Normal file
48
resources/views/components/calendar/week/week.blade.php
Normal file
@ -0,0 +1,48 @@
|
||||
@props([
|
||||
'grid' => [],
|
||||
'calendars' => [],
|
||||
'events' => [],
|
||||
'class' => '',
|
||||
'slots' => [],
|
||||
'timeformat' => '',
|
||||
'hgroup' => [],
|
||||
'active' => [],
|
||||
'density' => '30',
|
||||
'weekstart' => 0,
|
||||
'now' => [],
|
||||
])
|
||||
|
||||
<section
|
||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}" data-weekstart="{{ $weekstart }}"
|
||||
style=
|
||||
"--now-row: {{ (int) $now['row'] }};
|
||||
--now-col-start: {{ (int) $now['col_start'] }};
|
||||
--now-col-end: {{ (int) $now['col_end'] }};"
|
||||
>
|
||||
<hgroup>
|
||||
@foreach ($hgroup as $h)
|
||||
<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>
|
||||
</div>
|
||||
@endforeach
|
||||
</hgroup>
|
||||
<ol class="time" aria-label="{{ __('Times') }}">
|
||||
@foreach ($slots as $slot)
|
||||
<li>
|
||||
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
<ol class="events" aria-label="{{ __('Events') }}">
|
||||
@foreach ($events as $event)
|
||||
<x-calendar.time.event :event="$event" />
|
||||
@endforeach
|
||||
@if ($now['show'])
|
||||
<li class="now-indicator" aria-hidden="true"></li>
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<x-calendar.time.density view="week" :density="$density" />
|
||||
</footer>
|
||||
</section>
|
||||
@ -2,6 +2,14 @@
|
||||
<details open>
|
||||
<summary>{{ __('General settings') }}</summary>
|
||||
<menu class="content pagelinks">
|
||||
<li>
|
||||
<x-app.pagelink
|
||||
href="{{ route('calendar.settings.display') }}"
|
||||
:active="request()->routeIs('calendar.settings.display')"
|
||||
:label="__('calendar.settings.display.title')"
|
||||
icon="preferences"
|
||||
/>
|
||||
</li>
|
||||
</menu>
|
||||
</details>
|
||||
<details open>
|
||||
|
||||
@ -84,6 +84,10 @@ Route::middleware('auth')->group(function ()
|
||||
// settings landing
|
||||
Route::get('settings', [CalendarSettingsController::class, 'index'])->name('settings');
|
||||
|
||||
// display preferences
|
||||
Route::get('/settings/display', [CalendarSettingsController::class, 'displayForm'])->name('settings.display');
|
||||
Route::post('/settings/display', [CalendarSettingsController::class, 'displayStore'])->name('settings.display.store');
|
||||
|
||||
// settings / subscribe to a calendar
|
||||
Route::get('settings/subscribe', [CalendarSettingsController::class, 'subscribeForm'])->name('settings.subscribe');
|
||||
Route::post('settings/subscribe', [CalendarSettingsController::class, 'subscribeStore'])->name('settings.subscribe.store');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user