Removes old stock input components; auto scrolls time-based calendars to a better starting time; fixes recurring event logic and stubs out new repeat form for event edit
This commit is contained in:
parent
baeb291db5
commit
236da90af2
@ -11,6 +11,7 @@ use App\Services\Calendar\CalendarRangeResolver;
|
|||||||
use App\Services\Calendar\CalendarViewBuilder;
|
use App\Services\Calendar\CalendarViewBuilder;
|
||||||
use App\Services\Calendar\CalendarSettingsPersister;
|
use App\Services\Calendar\CalendarSettingsPersister;
|
||||||
use App\Services\Event\EventRecurrence;
|
use App\Services\Event\EventRecurrence;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class CalendarController extends Controller
|
class CalendarController extends Controller
|
||||||
{
|
{
|
||||||
@ -137,6 +138,96 @@ class CalendarController extends Controller
|
|||||||
$daytimeHoursForView,
|
$daytimeHoursForView,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$allDayEvents = $events->filter(fn ($event) => !empty($event['all_day']));
|
||||||
|
$timeEvents = $events->filter(fn ($event) => empty($event['all_day']));
|
||||||
|
$hasAllDayEvents = $allDayEvents->isNotEmpty();
|
||||||
|
|
||||||
|
if ($request->boolean('debug_all_day')) {
|
||||||
|
$calendarIds = $calendars->pluck('id')->values();
|
||||||
|
$event13CalendarId = Event::where('id', 13)->value('calendarid');
|
||||||
|
$rangeStart = $span['start']->toDateTimeString();
|
||||||
|
$rangeEnd = $span['end']->toDateTimeString();
|
||||||
|
|
||||||
|
$metaRangeCount = Event::whereIn('calendarid', $calendarIds)
|
||||||
|
->whereHas('meta', function ($meta) use ($rangeStart, $rangeEnd) {
|
||||||
|
$meta->where(function ($range) use ($rangeStart, $rangeEnd) {
|
||||||
|
$range->where('start_at', '<=', $rangeEnd)
|
||||||
|
->where(function ($bounds) use ($rangeStart) {
|
||||||
|
$bounds->where('end_at', '>=', $rangeStart)
|
||||||
|
->orWhereNull('end_at');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$metaRruleCount = Event::whereIn('calendarid', $calendarIds)
|
||||||
|
->whereHas('meta', function ($meta) {
|
||||||
|
$meta->whereNotNull('extra->rrule');
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$icalRruleCount = Event::whereIn('calendarid', $calendarIds)
|
||||||
|
->where(function ($ical) {
|
||||||
|
$ical->where('calendardata', 'like', '%RRULE%')
|
||||||
|
->orWhere('calendardata', 'like', '%RDATE%')
|
||||||
|
->orWhere('calendardata', 'like', '%EXDATE%');
|
||||||
|
})
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$event13Range = Event::where('id', 13)
|
||||||
|
->whereHas('meta', function ($meta) use ($rangeStart, $rangeEnd) {
|
||||||
|
$meta->where(function ($range) use ($rangeStart, $rangeEnd) {
|
||||||
|
$range->where('start_at', '<=', $rangeEnd)
|
||||||
|
->where(function ($bounds) use ($rangeStart) {
|
||||||
|
$bounds->where('end_at', '>=', $rangeStart)
|
||||||
|
->orWhereNull('end_at');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$event13Rrule = Event::where('id', 13)
|
||||||
|
->whereHas('meta', function ($meta) {
|
||||||
|
$meta->whereNotNull('extra->rrule');
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$event13Ical = Event::where('id', 13)
|
||||||
|
->where(function ($ical) {
|
||||||
|
$ical->where('calendardata', 'like', '%RRULE%')
|
||||||
|
->orWhere('calendardata', 'like', '%RDATE%')
|
||||||
|
->orWhere('calendardata', 'like', '%EXDATE%');
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
Log::info('calendar all-day debug', [
|
||||||
|
'view' => $view,
|
||||||
|
'range_start' => $range['start']->toDateTimeString(),
|
||||||
|
'range_end' => $range['end']->toDateTimeString(),
|
||||||
|
'span_start' => $span['start']->toDateTimeString(),
|
||||||
|
'span_end' => $span['end']->toDateTimeString(),
|
||||||
|
'meta_range_count' => $metaRangeCount,
|
||||||
|
'meta_rrule_count' => $metaRruleCount,
|
||||||
|
'ical_rrule_count' => $icalRruleCount,
|
||||||
|
'event_13_range' => $event13Range,
|
||||||
|
'event_13_meta_rrule' => $event13Rrule,
|
||||||
|
'event_13_ical_rrule' => $event13Ical,
|
||||||
|
'calendar_ids' => $calendarIds->all(),
|
||||||
|
'events_for_calendars' => Event::whereIn('calendarid', $calendarIds)->count(),
|
||||||
|
'event_13_calendar_id' => $event13CalendarId,
|
||||||
|
'event_13_in_scope' => $event13CalendarId ? $calendarIds->contains($event13CalendarId) : false,
|
||||||
|
'events_total' => $events->count(),
|
||||||
|
'events_all_day' => $allDayEvents->count(),
|
||||||
|
'events_time' => $timeEvents->count(),
|
||||||
|
'all_day_sample' => $allDayEvents->take(5)->values()->map(fn ($e) => [
|
||||||
|
'id' => $e['id'] ?? null,
|
||||||
|
'occurrence' => $e['occurrence'] ?? null,
|
||||||
|
'start' => $e['start'] ?? null,
|
||||||
|
'end' => $e['end'] ?? null,
|
||||||
|
'timezone' => $e['timezone'] ?? null,
|
||||||
|
])->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* mini calendar
|
* mini calendar
|
||||||
@ -205,6 +296,9 @@ class CalendarController extends Controller
|
|||||||
'week_start' => $weekStart,
|
'week_start' => $weekStart,
|
||||||
'hgroup' => $viewBuilder->viewHeaders($view, $range, $tz, $weekStart),
|
'hgroup' => $viewBuilder->viewHeaders($view, $range, $tz, $weekStart),
|
||||||
'events' => $events, // keyed by occurrence
|
'events' => $events, // keyed by occurrence
|
||||||
|
'events_time' => $timeEvents,
|
||||||
|
'events_all_day'=> $allDayEvents,
|
||||||
|
'has_all_day' => $hasAllDayEvents,
|
||||||
'grid' => $grid, // day objects hold only ID-sets
|
'grid' => $grid, // day objects hold only ID-sets
|
||||||
'mini' => $mini, // mini calendar days with events for indicators
|
'mini' => $mini, // mini calendar days with events for indicators
|
||||||
'mini_nav' => $mini_nav, // separate mini calendar navigation
|
'mini_nav' => $mini_nav, // separate mini calendar navigation
|
||||||
|
|||||||
@ -49,7 +49,7 @@ class EventController extends Controller
|
|||||||
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
|
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
|
||||||
$rrule = '';
|
$rrule = '';
|
||||||
|
|
||||||
return view('event.form', compact(
|
$data = compact(
|
||||||
'calendar',
|
'calendar',
|
||||||
'instance',
|
'instance',
|
||||||
'event',
|
'event',
|
||||||
@ -57,7 +57,13 @@ class EventController extends Controller
|
|||||||
'end',
|
'end',
|
||||||
'tz',
|
'tz',
|
||||||
'rrule',
|
'rrule',
|
||||||
));
|
);
|
||||||
|
|
||||||
|
if ($request->header('HX-Request') === 'true') {
|
||||||
|
return view('event.partials.form-modal', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('event.form', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -89,7 +95,13 @@ class EventController extends Controller
|
|||||||
?? $recurrence->extractRrule($event)
|
?? $recurrence->extractRrule($event)
|
||||||
?? '';
|
?? '';
|
||||||
|
|
||||||
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'));
|
$data = compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule');
|
||||||
|
|
||||||
|
if ($request->header('HX-Request') === 'true') {
|
||||||
|
return view('event.partials.form-modal', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('event.form', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -149,6 +161,10 @@ class EventController extends Controller
|
|||||||
$start = $startUtc->copy()->timezone($tz);
|
$start = $startUtc->copy()->timezone($tz);
|
||||||
$end = $endUtc->copy()->timezone($tz);
|
$end = $endUtc->copy()->timezone($tz);
|
||||||
|
|
||||||
|
if ($event->meta?->all_day && $end->gt($start)) {
|
||||||
|
$end = $end->copy()->subDay();
|
||||||
|
}
|
||||||
|
|
||||||
$map = $this->buildBasemapTiles($event->meta?->venue);
|
$map = $this->buildBasemapTiles($event->meta?->venue);
|
||||||
|
|
||||||
$event->setAttribute('color', $calendarColor);
|
$event->setAttribute('color', $calendarColor);
|
||||||
@ -168,15 +184,32 @@ class EventController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorize('update', $calendar);
|
$this->authorize('update', $calendar);
|
||||||
|
|
||||||
$data = $request->validate([
|
$this->normalizeRecurrenceInputs($request);
|
||||||
|
|
||||||
|
$rules = [
|
||||||
'title' => ['required', 'string', 'max:200'],
|
'title' => ['required', 'string', 'max:200'],
|
||||||
'start_at' => ['required', 'date_format:Y-m-d\TH:i'],
|
|
||||||
'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'],
|
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string'],
|
||||||
'location' => ['nullable', 'string'],
|
'location' => ['nullable', 'string'],
|
||||||
'all_day' => ['sometimes', 'boolean'],
|
'all_day' => ['sometimes', 'boolean'],
|
||||||
'category' => ['nullable', 'string', 'max:50'],
|
'category' => ['nullable', 'string', 'max:50'],
|
||||||
'rrule' => ['nullable', 'string', 'max:255'],
|
'repeat_frequency' => [
|
||||||
|
function (string $attribute, mixed $value, $fail) {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!in_array($value, ['daily', 'weekly', 'monthly', 'yearly'], true)) {
|
||||||
|
$fail(__('calendar.event.recurrence.invalid_frequency'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'repeat_interval' => ['nullable', 'integer', 'min:1', 'max:365'],
|
||||||
|
'repeat_weekdays' => ['nullable', 'array'],
|
||||||
|
'repeat_weekdays.*' => ['in:SU,MO,TU,WE,TH,FR,SA'],
|
||||||
|
'repeat_monthly_mode' => ['nullable', 'in:days,weekday'],
|
||||||
|
'repeat_month_days' => ['nullable', 'array'],
|
||||||
|
'repeat_month_days.*' => ['integer', 'min:1', 'max:31'],
|
||||||
|
'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'],
|
||||||
|
'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'],
|
||||||
|
|
||||||
// normalized location hints (optional)
|
// normalized location hints (optional)
|
||||||
'loc_display_name' => ['nullable', 'string'],
|
'loc_display_name' => ['nullable', 'string'],
|
||||||
@ -188,23 +221,47 @@ class EventController extends Controller
|
|||||||
'loc_country' => ['nullable', 'string'],
|
'loc_country' => ['nullable', 'string'],
|
||||||
'loc_lat' => ['nullable'],
|
'loc_lat' => ['nullable'],
|
||||||
'loc_lon' => ['nullable'],
|
'loc_lon' => ['nullable'],
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($request->boolean('all_day')) {
|
||||||
|
$rules['start_at'] = ['required', 'date'];
|
||||||
|
$rules['end_at'] = ['required', 'date', 'after_or_equal:start_at'];
|
||||||
|
} else {
|
||||||
|
$rules['start_at'] = ['required', 'date_format:Y-m-d\\TH:i'];
|
||||||
|
$rules['end_at'] = ['required', 'date_format:Y-m-d\\TH:i', 'after:start_at'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate($rules);
|
||||||
|
|
||||||
$tz = $this->displayTimezone($calendar, $request);
|
$tz = $this->displayTimezone($calendar, $request);
|
||||||
|
$isAllDay = (bool) ($data['all_day'] ?? false);
|
||||||
|
|
||||||
// parse input in display tz, store in utc
|
// parse input in display tz, store in utc
|
||||||
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
|
if ($isAllDay) {
|
||||||
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
|
[$startOn, $endOn, $startUtc, $endUtc] = $this->deriveAllDayRange(
|
||||||
|
$data['start_at'],
|
||||||
|
$data['end_at'],
|
||||||
|
$tz
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$startOn = null;
|
||||||
|
$endOn = null;
|
||||||
|
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
|
||||||
|
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
|
||||||
|
}
|
||||||
|
|
||||||
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
|
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
|
||||||
|
|
||||||
$rrule = $this->normalizeRrule($request);
|
$rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay));
|
||||||
$extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
|
$extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
|
||||||
|
|
||||||
$ical = $recurrence->buildCalendar([
|
$ical = $recurrence->buildCalendar([
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'start_utc' => $startUtc,
|
'start_utc' => $startUtc,
|
||||||
'end_utc' => $endUtc,
|
'end_utc' => $endUtc,
|
||||||
|
'all_day' => $isAllDay,
|
||||||
|
'start_date' => $startOn,
|
||||||
|
'end_date' => $endOn,
|
||||||
'summary' => $data['title'],
|
'summary' => $data['title'],
|
||||||
'description' => $data['description'] ?? '',
|
'description' => $data['description'] ?? '',
|
||||||
'location' => $data['location'] ?? '',
|
'location' => $data['location'] ?? '',
|
||||||
@ -230,10 +287,13 @@ class EventController extends Controller
|
|||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
'location' => $data['location'] ?? null,
|
'location' => $data['location'] ?? null,
|
||||||
'location_id' => $locationId,
|
'location_id' => $locationId,
|
||||||
'all_day' => (bool) ($data['all_day'] ?? false),
|
'all_day' => $isAllDay,
|
||||||
'category' => $data['category'] ?? null,
|
'category' => $data['category'] ?? null,
|
||||||
'start_at' => $startUtc,
|
'start_at' => $startUtc,
|
||||||
'end_at' => $endUtc,
|
'end_at' => $endUtc,
|
||||||
|
'start_on' => $startOn,
|
||||||
|
'end_on' => $endOn,
|
||||||
|
'tzid' => $isAllDay ? $tz : null,
|
||||||
'extra' => $extra,
|
'extra' => $extra,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -251,25 +311,63 @@ class EventController extends Controller
|
|||||||
abort(Response::HTTP_NOT_FOUND);
|
abort(Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $request->validate([
|
$this->normalizeRecurrenceInputs($request);
|
||||||
|
|
||||||
|
$rules = [
|
||||||
'title' => ['required', 'string', 'max:200'],
|
'title' => ['required', 'string', 'max:200'],
|
||||||
'start_at' => ['required', 'date_format:Y-m-d\TH:i'],
|
|
||||||
'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'],
|
|
||||||
'description' => ['nullable', 'string'],
|
'description' => ['nullable', 'string'],
|
||||||
'location' => ['nullable', 'string'],
|
'location' => ['nullable', 'string'],
|
||||||
'all_day' => ['sometimes', 'boolean'],
|
'all_day' => ['sometimes', 'boolean'],
|
||||||
'category' => ['nullable', 'string', 'max:50'],
|
'category' => ['nullable', 'string', 'max:50'],
|
||||||
'rrule' => ['nullable', 'string', 'max:255'],
|
'repeat_frequency' => [
|
||||||
]);
|
function (string $attribute, mixed $value, $fail) {
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!in_array($value, ['daily', 'weekly', 'monthly', 'yearly'], true)) {
|
||||||
|
$fail(__('calendar.event.recurrence.invalid_frequency'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'repeat_interval' => ['nullable', 'integer', 'min:1', 'max:365'],
|
||||||
|
'repeat_weekdays' => ['nullable', 'array'],
|
||||||
|
'repeat_weekdays.*' => ['in:SU,MO,TU,WE,TH,FR,SA'],
|
||||||
|
'repeat_monthly_mode' => ['nullable', 'in:days,weekday'],
|
||||||
|
'repeat_month_days' => ['nullable', 'array'],
|
||||||
|
'repeat_month_days.*' => ['integer', 'min:1', 'max:31'],
|
||||||
|
'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'],
|
||||||
|
'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($request->boolean('all_day')) {
|
||||||
|
$rules['start_at'] = ['required', 'date'];
|
||||||
|
$rules['end_at'] = ['required', 'date', 'after_or_equal:start_at'];
|
||||||
|
} else {
|
||||||
|
$rules['start_at'] = ['required', 'date_format:Y-m-d\\TH:i'];
|
||||||
|
$rules['end_at'] = ['required', 'date_format:Y-m-d\\TH:i', 'after:start_at'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate($rules);
|
||||||
|
|
||||||
$tz = $this->displayTimezone($calendar, $request);
|
$tz = $this->displayTimezone($calendar, $request);
|
||||||
|
$isAllDay = (bool) ($data['all_day'] ?? false);
|
||||||
|
|
||||||
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
|
if ($isAllDay) {
|
||||||
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
|
[$startOn, $endOn, $startUtc, $endUtc] = $this->deriveAllDayRange(
|
||||||
|
$data['start_at'],
|
||||||
|
$data['end_at'],
|
||||||
|
$tz
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$startOn = null;
|
||||||
|
$endOn = null;
|
||||||
|
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
|
||||||
|
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
|
||||||
|
}
|
||||||
|
|
||||||
$uid = $event->uid;
|
$uid = $event->uid;
|
||||||
|
|
||||||
$rrule = $this->normalizeRrule($request);
|
$rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay));
|
||||||
$extra = $event->meta?->extra ?? [];
|
$extra = $event->meta?->extra ?? [];
|
||||||
$extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request);
|
$extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request);
|
||||||
$rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event));
|
$rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event));
|
||||||
@ -278,6 +376,9 @@ class EventController extends Controller
|
|||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'start_utc' => $startUtc,
|
'start_utc' => $startUtc,
|
||||||
'end_utc' => $endUtc,
|
'end_utc' => $endUtc,
|
||||||
|
'all_day' => $isAllDay,
|
||||||
|
'start_date' => $startOn,
|
||||||
|
'end_date' => $endOn,
|
||||||
'summary' => $data['title'],
|
'summary' => $data['title'],
|
||||||
'description' => $data['description'] ?? '',
|
'description' => $data['description'] ?? '',
|
||||||
'location' => $data['location'] ?? '',
|
'location' => $data['location'] ?? '',
|
||||||
@ -297,10 +398,13 @@ class EventController extends Controller
|
|||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
'location' => $data['location'] ?? null,
|
'location' => $data['location'] ?? null,
|
||||||
'all_day' => (bool) ($data['all_day'] ?? false),
|
'all_day' => $isAllDay,
|
||||||
'category' => $data['category'] ?? null,
|
'category' => $data['category'] ?? null,
|
||||||
'start_at' => $startUtc,
|
'start_at' => $startUtc,
|
||||||
'end_at' => $endUtc,
|
'end_at' => $endUtc,
|
||||||
|
'start_on' => $startOn,
|
||||||
|
'end_on' => $endOn,
|
||||||
|
'tzid' => $isAllDay ? $tz : null,
|
||||||
'extra' => $extra,
|
'extra' => $extra,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -328,6 +432,40 @@ class EventController extends Controller
|
|||||||
return $local->utc();
|
return $local->utc();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* derive all-day date range (exclusive end) and utc bounds
|
||||||
|
*/
|
||||||
|
private function deriveAllDayRange(string $start, string $end, string $tz): array
|
||||||
|
{
|
||||||
|
$startLocal = $this->parseAllDayInput($start, $tz);
|
||||||
|
$endLocal = $this->parseAllDayInput($end, $tz);
|
||||||
|
|
||||||
|
$startOn = $startLocal->toDateString();
|
||||||
|
$endOn = $endLocal->toDateString();
|
||||||
|
|
||||||
|
if (Carbon::parse($endOn, $tz)->lte(Carbon::parse($startOn, $tz))) {
|
||||||
|
$endOn = Carbon::parse($startOn, $tz)->addDay()->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
$startUtc = Carbon::parse($startOn, $tz)->startOfDay()->utc();
|
||||||
|
$endUtc = Carbon::parse($endOn, $tz)->startOfDay()->utc();
|
||||||
|
|
||||||
|
return [$startOn, $endOn, $startUtc, $endUtc];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseAllDayInput(string $value, string $tz): Carbon
|
||||||
|
{
|
||||||
|
if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1) {
|
||||||
|
return Carbon::createFromFormat('Y-m-d', $value, $tz)->startOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::createFromFormat('Y-m-d\\TH:i', $value, $tz)->seconds(0);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return Carbon::parse($value, $tz)->startOfDay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* minimal ics escaping for text properties
|
* minimal ics escaping for text properties
|
||||||
*/
|
*/
|
||||||
@ -338,14 +476,89 @@ class EventController extends Controller
|
|||||||
return $text;
|
return $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeRrule(Request $request): ?string
|
private function normalizeRecurrenceInputs(Request $request): void
|
||||||
{
|
{
|
||||||
if (! $request->has('rrule')) {
|
if ($request->has('repeat_interval') && $request->input('repeat_interval') === '') {
|
||||||
|
$request->merge(['repeat_interval' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildRruleFromRequest(Request $request, Carbon $startLocal): ?string
|
||||||
|
{
|
||||||
|
if (! $request->has('repeat_frequency')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rrule = trim((string) $request->input('rrule'));
|
$freq = strtolower((string) $request->input('repeat_frequency', ''));
|
||||||
return $rrule === '' ? '' : $rrule;
|
if ($freq === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$interval = max(1, (int) $request->input('repeat_interval', 1));
|
||||||
|
$parts = ['FREQ=' . strtoupper($freq)];
|
||||||
|
if ($interval > 1) {
|
||||||
|
$parts[] = 'INTERVAL=' . $interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weekdayMap = [
|
||||||
|
'sun' => 'SU',
|
||||||
|
'mon' => 'MO',
|
||||||
|
'tue' => 'TU',
|
||||||
|
'wed' => 'WE',
|
||||||
|
'thu' => 'TH',
|
||||||
|
'fri' => 'FR',
|
||||||
|
'sat' => 'SA',
|
||||||
|
];
|
||||||
|
$defaultWeekday = $weekdayMap[strtolower($startLocal->format('D'))] ?? 'MO';
|
||||||
|
|
||||||
|
if ($freq === 'weekly') {
|
||||||
|
$days = (array) $request->input('repeat_weekdays', []);
|
||||||
|
$days = array_values(array_filter($days, fn ($d) => preg_match('/^(SU|MO|TU|WE|TH|FR|SA)$/', $d)));
|
||||||
|
if (empty($days)) {
|
||||||
|
$days = [$defaultWeekday];
|
||||||
|
}
|
||||||
|
$parts[] = 'BYDAY=' . implode(',', $days);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($freq === 'monthly') {
|
||||||
|
$mode = $request->input('repeat_monthly_mode', 'days');
|
||||||
|
if ($mode === 'weekday') {
|
||||||
|
$week = $request->input('repeat_month_week', 'first');
|
||||||
|
$weekday = $request->input('repeat_month_weekday', $defaultWeekday);
|
||||||
|
|
||||||
|
$weekMap = [
|
||||||
|
'first' => 1,
|
||||||
|
'second' => 2,
|
||||||
|
'third' => 3,
|
||||||
|
'fourth' => 4,
|
||||||
|
'last' => -1,
|
||||||
|
];
|
||||||
|
$bysetpos = $weekMap[$week] ?? 1;
|
||||||
|
$parts[] = 'BYDAY=' . $weekday;
|
||||||
|
$parts[] = 'BYSETPOS=' . $bysetpos;
|
||||||
|
} else {
|
||||||
|
$days = (array) $request->input('repeat_month_days', []);
|
||||||
|
$days = array_values(array_filter($days, fn ($d) => is_numeric($d) && (int) $d >= 1 && (int) $d <= 31));
|
||||||
|
if (empty($days)) {
|
||||||
|
$days = [(int) $startLocal->format('j')];
|
||||||
|
}
|
||||||
|
$parts[] = 'BYMONTHDAY=' . implode(',', $days);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($freq === 'yearly') {
|
||||||
|
$parts[] = 'BYMONTH=' . $startLocal->format('n');
|
||||||
|
$parts[] = 'BYMONTHDAY=' . $startLocal->format('j');
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(';', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseLocalInputToTz(string $value, string $tz, bool $isAllDay): Carbon
|
||||||
|
{
|
||||||
|
return $isAllDay
|
||||||
|
? $this->parseAllDayInput($value, $tz)
|
||||||
|
: Carbon::createFromFormat('Y-m-d\\TH:i', $value, $tz)->seconds(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array
|
private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array
|
||||||
|
|||||||
@ -60,19 +60,26 @@ class IcsController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$meta = $event->meta;
|
$meta = $event->meta;
|
||||||
if (!$meta || !$meta->start_at || !$meta->end_at) {
|
if (!$meta) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$start = Carbon::parse($meta->start_at)->timezone($tz);
|
|
||||||
$end = Carbon::parse($meta->end_at)->timezone($tz);
|
|
||||||
|
|
||||||
$vevent = $vcalendar->add('VEVENT', []);
|
$vevent = $vcalendar->add('VEVENT', []);
|
||||||
$vevent->add('UID', $event->uid);
|
$vevent->add('UID', $event->uid);
|
||||||
$vevent->add('SUMMARY', $meta->title ?? '(Untitled)');
|
$vevent->add('SUMMARY', $meta->title ?? '(Untitled)');
|
||||||
$vevent->add('DESCRIPTION', $meta->description ?? '');
|
$vevent->add('DESCRIPTION', $meta->description ?? '');
|
||||||
$vevent->add('DTSTART', $start, ['TZID' => $tz]);
|
|
||||||
$vevent->add('DTEND', $end, ['TZID' => $tz]);
|
if ($meta->all_day && $meta->start_on && $meta->end_on) {
|
||||||
|
$vevent->add('DTSTART', Carbon::parse($meta->start_on), ['VALUE' => 'DATE']);
|
||||||
|
$vevent->add('DTEND', Carbon::parse($meta->end_on), ['VALUE' => 'DATE']);
|
||||||
|
} elseif ($meta->start_at && $meta->end_at) {
|
||||||
|
$start = Carbon::parse($meta->start_at)->timezone($tz);
|
||||||
|
$end = Carbon::parse($meta->end_at)->timezone($tz);
|
||||||
|
$vevent->add('DTSTART', $start, ['TZID' => $tz]);
|
||||||
|
$vevent->add('DTEND', $end, ['TZID' => $tz]);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if ($event->lastmodified) {
|
if ($event->lastmodified) {
|
||||||
$vevent->add('DTSTAMP', Carbon::createFromTimestamp($event->lastmodified)->utc());
|
$vevent->add('DTSTAMP', Carbon::createFromTimestamp($event->lastmodified)->utc());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -147,14 +147,29 @@ class SyncSubscription implements ShouldQueue
|
|||||||
$end = isset($vevent->DTEND)
|
$end = isset($vevent->DTEND)
|
||||||
? Carbon::instance($vevent->DTEND->getDateTime())->utc()
|
? Carbon::instance($vevent->DTEND->getDateTime())->utc()
|
||||||
: $start;
|
: $start;
|
||||||
|
$isAllDay = $dtStart->isFloating();
|
||||||
|
$startOn = null;
|
||||||
|
$endOn = null;
|
||||||
|
$tzid = null;
|
||||||
|
|
||||||
|
if ($isAllDay) {
|
||||||
|
$startOn = $start->toDateString();
|
||||||
|
$endOn = $end->toDateString();
|
||||||
|
if (Carbon::parse($endOn)->lte(Carbon::parse($startOn))) {
|
||||||
|
$endOn = Carbon::parse($startOn)->addDay()->toDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
EventMeta::upsertForEvent($object->id, [
|
EventMeta::upsertForEvent($object->id, [
|
||||||
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
|
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
|
||||||
'description' => (string) ($vevent->DESCRIPTION ?? ''),
|
'description' => (string) ($vevent->DESCRIPTION ?? ''),
|
||||||
'location' => (string) ($vevent->LOCATION ?? ''),
|
'location' => (string) ($vevent->LOCATION ?? ''),
|
||||||
'all_day' => $dtStart->isFloating(),
|
'all_day' => $isAllDay,
|
||||||
'start_at' => $start,
|
'start_at' => $start,
|
||||||
'end_at' => $end,
|
'end_at' => $end,
|
||||||
|
'start_on' => $startOn,
|
||||||
|
'end_on' => $endOn,
|
||||||
|
'tzid' => $tzid,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,9 @@ class EventMeta extends Model
|
|||||||
'is_private',
|
'is_private',
|
||||||
'start_at',
|
'start_at',
|
||||||
'end_at',
|
'end_at',
|
||||||
|
'start_on',
|
||||||
|
'end_on',
|
||||||
|
'tzid',
|
||||||
'extra',
|
'extra',
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -30,6 +33,8 @@ class EventMeta extends Model
|
|||||||
'is_private' => 'boolean',
|
'is_private' => 'boolean',
|
||||||
'start_at' => 'datetime',
|
'start_at' => 'datetime',
|
||||||
'end_at' => 'datetime',
|
'end_at' => 'datetime',
|
||||||
|
'start_on' => 'date',
|
||||||
|
'end_on' => 'date',
|
||||||
'extra' => 'array',
|
'extra' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -90,6 +90,7 @@ class CalendarViewBuilder
|
|||||||
) {
|
) {
|
||||||
$startUtc = $occ['start'];
|
$startUtc = $occ['start'];
|
||||||
$endUtc = $occ['end'];
|
$endUtc = $occ['end'];
|
||||||
|
$isAllDay = (bool) ($e->meta?->all_day ?? false);
|
||||||
|
|
||||||
$startLocal = $startUtc->copy()->timezone($timezone);
|
$startLocal = $startUtc->copy()->timezone($timezone);
|
||||||
$endLocal = $endUtc->copy()->timezone($timezone);
|
$endLocal = $endUtc->copy()->timezone($timezone);
|
||||||
@ -97,7 +98,7 @@ class CalendarViewBuilder
|
|||||||
$startForGrid = $startUtc->copy()->tz($tz);
|
$startForGrid = $startUtc->copy()->tz($tz);
|
||||||
$endForGrid = $endUtc->copy()->tz($tz);
|
$endForGrid = $endUtc->copy()->tz($tz);
|
||||||
|
|
||||||
if ($daytimeHours) {
|
if ($daytimeHours && !$isAllDay) {
|
||||||
$startMinutes = ($startForGrid->hour * 60) + $startForGrid->minute;
|
$startMinutes = ($startForGrid->hour * 60) + $startForGrid->minute;
|
||||||
$endMinutes = ($endForGrid->hour * 60) + $endForGrid->minute;
|
$endMinutes = ($endForGrid->hour * 60) + $endForGrid->minute;
|
||||||
|
|
||||||
@ -136,6 +137,7 @@ class CalendarViewBuilder
|
|||||||
'calendar_slug' => $cal->slug,
|
'calendar_slug' => $cal->slug,
|
||||||
'title' => $e->meta->title ?? 'No title',
|
'title' => $e->meta->title ?? 'No title',
|
||||||
'description' => $e->meta->description ?? 'No description.',
|
'description' => $e->meta->description ?? 'No description.',
|
||||||
|
'all_day' => $isAllDay,
|
||||||
'start' => $startUtc->toIso8601String(),
|
'start' => $startUtc->toIso8601String(),
|
||||||
'end' => $endUtc->toIso8601String(),
|
'end' => $endUtc->toIso8601String(),
|
||||||
'start_ui' => $startLocal->format($uiFormat),
|
'start_ui' => $startLocal->format($uiFormat),
|
||||||
@ -179,6 +181,9 @@ class CalendarViewBuilder
|
|||||||
$evTz = $ev['timezone'] ?? $tz;
|
$evTz = $ev['timezone'] ?? $tz;
|
||||||
$start = Carbon::parse($ev['start'])->tz($evTz);
|
$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;
|
||||||
|
if (!empty($ev['all_day']) && $end->gt($start)) {
|
||||||
|
$end = $end->copy()->subSecond();
|
||||||
|
}
|
||||||
|
|
||||||
for ($d = $start->copy()->startOfDay();
|
for ($d = $start->copy()->startOfDay();
|
||||||
$d->lte($end->copy()->endOfDay());
|
$d->lte($end->copy()->endOfDay());
|
||||||
|
|||||||
@ -27,11 +27,17 @@ class EventRecurrence
|
|||||||
$startUtc = $data['start_utc'];
|
$startUtc = $data['start_utc'];
|
||||||
$endUtc = $data['end_utc'];
|
$endUtc = $data['end_utc'];
|
||||||
$tzid = $data['tzid'] ?? null;
|
$tzid = $data['tzid'] ?? null;
|
||||||
|
$allDay = (bool) ($data['all_day'] ?? false);
|
||||||
|
$startDate = $data['start_date'] ?? null;
|
||||||
|
$endDate = $data['end_date'] ?? null;
|
||||||
|
|
||||||
$vevent->add('UID', $uid);
|
$vevent->add('UID', $uid);
|
||||||
$vevent->add('DTSTAMP', $startUtc->copy()->utc());
|
$vevent->add('DTSTAMP', $startUtc->copy()->utc());
|
||||||
|
|
||||||
if ($tzid) {
|
if ($allDay && $startDate && $endDate) {
|
||||||
|
$vevent->add('DTSTART', Carbon::parse($startDate), ['VALUE' => 'DATE']);
|
||||||
|
$vevent->add('DTEND', Carbon::parse($endDate), ['VALUE' => 'DATE']);
|
||||||
|
} elseif ($tzid) {
|
||||||
$startLocal = $startUtc->copy()->tz($tzid);
|
$startLocal = $startUtc->copy()->tz($tzid);
|
||||||
$endLocal = $endUtc->copy()->tz($tzid);
|
$endLocal = $endUtc->copy()->tz($tzid);
|
||||||
$vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]);
|
$vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]);
|
||||||
@ -62,7 +68,9 @@ class EventRecurrence
|
|||||||
if (!empty($exdates)) {
|
if (!empty($exdates)) {
|
||||||
foreach ($exdates as $ex) {
|
foreach ($exdates as $ex) {
|
||||||
$dt = Carbon::parse($ex, $tzid ?: 'UTC');
|
$dt = Carbon::parse($ex, $tzid ?: 'UTC');
|
||||||
if ($tzid) {
|
if ($allDay) {
|
||||||
|
$vevent->add('EXDATE', Carbon::parse($dt->toDateString()), ['VALUE' => 'DATE']);
|
||||||
|
} elseif ($tzid) {
|
||||||
$vevent->add('EXDATE', $dt, ['TZID' => $tzid]);
|
$vevent->add('EXDATE', $dt, ['TZID' => $tzid]);
|
||||||
} else {
|
} else {
|
||||||
$vevent->add('EXDATE', $dt->utc());
|
$vevent->add('EXDATE', $dt->utc());
|
||||||
@ -74,7 +82,9 @@ class EventRecurrence
|
|||||||
if (!empty($rdates)) {
|
if (!empty($rdates)) {
|
||||||
foreach ($rdates as $r) {
|
foreach ($rdates as $r) {
|
||||||
$dt = Carbon::parse($r, $tzid ?: 'UTC');
|
$dt = Carbon::parse($r, $tzid ?: 'UTC');
|
||||||
if ($tzid) {
|
if ($allDay) {
|
||||||
|
$vevent->add('RDATE', Carbon::parse($dt->toDateString()), ['VALUE' => 'DATE']);
|
||||||
|
} elseif ($tzid) {
|
||||||
$vevent->add('RDATE', $dt, ['TZID' => $tzid]);
|
$vevent->add('RDATE', $dt, ['TZID' => $tzid]);
|
||||||
} else {
|
} else {
|
||||||
$vevent->add('RDATE', $dt->utc());
|
$vevent->add('RDATE', $dt->utc());
|
||||||
@ -117,19 +127,43 @@ class EventRecurrence
|
|||||||
?? new DateTimeZone('UTC');
|
?? new DateTimeZone('UTC');
|
||||||
|
|
||||||
$iter = new EventIterator($vcalendar, $uid);
|
$iter = new EventIterator($vcalendar, $uid);
|
||||||
$iter->fastForward($rangeStart->copy()->setTimezone($startTz)->toDateTime());
|
$baseStart = $vevent->DTSTART?->getDateTime();
|
||||||
|
$baseEnd = $vevent->DTEND?->getDateTime();
|
||||||
|
$durationSeconds = 0;
|
||||||
|
if ($baseStart && $baseEnd) {
|
||||||
|
$durationSeconds = max(0, $baseEnd->getTimestamp() - $baseStart->getTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchStart = $rangeStart->copy()->subSeconds($durationSeconds)->setTimezone($startTz);
|
||||||
|
$iter->fastForward($searchStart->toDateTime());
|
||||||
|
|
||||||
$items = [];
|
$items = [];
|
||||||
while ($iter->valid()) {
|
while ($iter->valid()) {
|
||||||
$start = Carbon::instance($iter->getDTStart());
|
$start = Carbon::instance($iter->getDTStart());
|
||||||
$end = Carbon::instance($iter->getDTEnd());
|
$end = Carbon::instance($iter->getDTEnd());
|
||||||
|
$allDayTz = null;
|
||||||
|
if ($event->meta?->all_day) {
|
||||||
|
$allDayTz = $event->meta?->tzid
|
||||||
|
?? ($event->meta?->extra['tzid'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
if ($start->gt($rangeEnd)) {
|
if ($allDayTz) {
|
||||||
break;
|
$start = Carbon::parse($start->toDateString(), $allDayTz);
|
||||||
|
$end = Carbon::parse($end->toDateString(), $allDayTz);
|
||||||
}
|
}
|
||||||
|
|
||||||
$startUtc = $start->copy()->utc();
|
$startUtc = $start->copy()->utc();
|
||||||
$endUtc = $end->copy()->utc();
|
$endUtc = $end->copy()->utc();
|
||||||
|
|
||||||
|
if ($startUtc->gt($rangeEnd)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($endUtc->lte($rangeStart)) {
|
||||||
|
$iter->next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'start' => $startUtc,
|
'start' => $startUtc,
|
||||||
'end' => $endUtc,
|
'end' => $endUtc,
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('event_meta', function (Blueprint $table) {
|
||||||
|
$table->date('start_on')->nullable()->after('end_at');
|
||||||
|
$table->date('end_on')->nullable()->after('start_on');
|
||||||
|
$table->string('tzid')->nullable()->after('end_on');
|
||||||
|
|
||||||
|
$table->index(['all_day', 'start_on']);
|
||||||
|
$table->index(['all_day', 'end_on']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('event_meta', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['all_day', 'start_on']);
|
||||||
|
$table->dropIndex(['all_day', 'end_on']);
|
||||||
|
|
||||||
|
$table->dropColumn(['start_on', 'end_on', 'tzid']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -81,6 +81,44 @@ return [
|
|||||||
'attachments_coming' => 'Attachment support coming soon.',
|
'attachments_coming' => 'Attachment support coming soon.',
|
||||||
'notes' => 'Notes',
|
'notes' => 'Notes',
|
||||||
'no_description' => 'No description yet.',
|
'no_description' => 'No description yet.',
|
||||||
|
'all_day_events' => 'All-day events',
|
||||||
|
'recurrence' => [
|
||||||
|
'label' => 'Repeat',
|
||||||
|
'frequency' => 'Frequency',
|
||||||
|
'none' => 'Does not repeat',
|
||||||
|
'every' => 'Every',
|
||||||
|
'daily' => 'Daily',
|
||||||
|
'weekly' => 'Weekly',
|
||||||
|
'monthly' => 'Monthly',
|
||||||
|
'yearly' => 'Yearly',
|
||||||
|
'on_days' => 'On days',
|
||||||
|
'on_the' => 'On the',
|
||||||
|
'yearly_hint' => 'Repeats on the same date each year.',
|
||||||
|
'invalid_frequency' => 'Please choose a valid repeat frequency.',
|
||||||
|
'weekdays' => [
|
||||||
|
'sun' => 'Sunday',
|
||||||
|
'mon' => 'Monday',
|
||||||
|
'tue' => 'Tuesday',
|
||||||
|
'wed' => 'Wednesday',
|
||||||
|
'thu' => 'Thursday',
|
||||||
|
'fri' => 'Friday',
|
||||||
|
'sat' => 'Saturday',
|
||||||
|
'sun_short' => 'S',
|
||||||
|
'mon_short' => 'M',
|
||||||
|
'tue_short' => 'T',
|
||||||
|
'wed_short' => 'W',
|
||||||
|
'thu_short' => 'T',
|
||||||
|
'fri_short' => 'F',
|
||||||
|
'sat_short' => 'S',
|
||||||
|
],
|
||||||
|
'week_order' => [
|
||||||
|
'first' => 'First',
|
||||||
|
'second' => 'Second',
|
||||||
|
'third' => 'Third',
|
||||||
|
'fourth' => 'Fourth',
|
||||||
|
'last' => 'Last',
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -81,6 +81,44 @@ return [
|
|||||||
'attachments_coming' => 'Supporto allegati in arrivo.',
|
'attachments_coming' => 'Supporto allegati in arrivo.',
|
||||||
'notes' => 'Note',
|
'notes' => 'Note',
|
||||||
'no_description' => 'Nessuna descrizione.',
|
'no_description' => 'Nessuna descrizione.',
|
||||||
|
'all_day_events' => 'Eventi di tutto il giorno',
|
||||||
|
'recurrence' => [
|
||||||
|
'label' => 'Ripeti',
|
||||||
|
'frequency' => 'Frequenza',
|
||||||
|
'none' => 'Non si ripete',
|
||||||
|
'every' => 'Ogni',
|
||||||
|
'daily' => 'Giornaliero',
|
||||||
|
'weekly' => 'Settimanale',
|
||||||
|
'monthly' => 'Mensile',
|
||||||
|
'yearly' => 'Annuale',
|
||||||
|
'on_days' => 'Nei giorni',
|
||||||
|
'on_the' => 'Nel',
|
||||||
|
'yearly_hint' => 'Si ripete nella stessa data ogni anno.',
|
||||||
|
'invalid_frequency' => 'Seleziona una frequenza valida.',
|
||||||
|
'weekdays' => [
|
||||||
|
'sun' => 'Domenica',
|
||||||
|
'mon' => 'Lunedi',
|
||||||
|
'tue' => 'Martedi',
|
||||||
|
'wed' => 'Mercoledi',
|
||||||
|
'thu' => 'Giovedi',
|
||||||
|
'fri' => 'Venerdi',
|
||||||
|
'sat' => 'Sabato',
|
||||||
|
'sun_short' => 'D',
|
||||||
|
'mon_short' => 'L',
|
||||||
|
'tue_short' => 'M',
|
||||||
|
'wed_short' => 'M',
|
||||||
|
'thu_short' => 'G',
|
||||||
|
'fri_short' => 'V',
|
||||||
|
'sat_short' => 'S',
|
||||||
|
],
|
||||||
|
'week_order' => [
|
||||||
|
'first' => 'Primo',
|
||||||
|
'second' => 'Secondo',
|
||||||
|
'third' => 'Terzo',
|
||||||
|
'fourth' => 'Quarto',
|
||||||
|
'last' => 'Ultimo',
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -146,6 +146,10 @@ button,
|
|||||||
|
|
||||||
/* small */
|
/* small */
|
||||||
&.button-group--sm {
|
&.button-group--sm {
|
||||||
@apply h-9 max-h-9 text-sm;
|
@apply h-9 max-h-9;
|
||||||
|
|
||||||
|
> label {
|
||||||
|
@apply text-sm;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -158,6 +158,12 @@
|
|||||||
--now-col-start: 1;
|
--now-col-start: 1;
|
||||||
--now-col-end: 2;
|
--now-col-end: 2;
|
||||||
|
|
||||||
|
/* if we have an all day event, change the grid */
|
||||||
|
&.allday {
|
||||||
|
grid-template-columns: 6rem auto;
|
||||||
|
grid-template-rows: 5rem min-content auto 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* top day bar */
|
/* top day bar */
|
||||||
hgroup {
|
hgroup {
|
||||||
@apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10;
|
@apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10;
|
||||||
@ -177,7 +183,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.day-header {
|
div.day-header {
|
||||||
@apply relative flex flex-col gap-1 justify-end items-start pb-2;
|
@apply relative flex flex-col gap-1 justify-end items-start pb-2 h-full;
|
||||||
animation: header-slide 250ms ease-in;
|
animation: header-slide 250ms ease-in;
|
||||||
|
|
||||||
&:not(:last-of-type)::after {
|
&:not(:last-of-type)::after {
|
||||||
@ -198,6 +204,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* all day bar */
|
||||||
|
ol.day {
|
||||||
|
@apply sticky top-42 grid col-span-2 bg-white py-2 border-b border-primary col-span-2 pl-24 z-2 overflow-x-hidden;
|
||||||
|
box-shadow: 0 0.25rem 0.5rem -0.25rem var(--color-gray-200);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
@apply absolute left-0 top-1/2 -translate-y-1/2 w-24 pr-4 text-right;
|
||||||
|
@apply uppercase text-xs font-mono text-secondary font-medium;
|
||||||
|
content: 'All day';
|
||||||
|
}
|
||||||
|
|
||||||
|
li.events {
|
||||||
|
@apply flex flex-col gap-1 relative overflow-x-hidden;
|
||||||
|
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);
|
||||||
|
|
||||||
|
a.event {
|
||||||
|
@apply flex items-center text-xs gap-1 px-1 py-px font-medium rounded-sm w-full;
|
||||||
|
background-color: var(--event-bg);
|
||||||
|
color: var(--event-fg);
|
||||||
|
|
||||||
|
> span {
|
||||||
|
@apply truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--event-bg) 100%, #000 10%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* time column */
|
/* time column */
|
||||||
ol.time {
|
ol.time {
|
||||||
@apply grid z-0 pt-4;
|
@apply grid z-0 pt-4;
|
||||||
@ -351,12 +391,17 @@
|
|||||||
|
|
||||||
hgroup {
|
hgroup {
|
||||||
@apply grid gap-x-2;
|
@apply grid gap-x-2;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(var(--days), 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.day {
|
||||||
|
@apply gap-x-2;
|
||||||
|
grid-template-columns: repeat(var(--days), 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
ol.events {
|
ol.events {
|
||||||
@apply gap-x-2;
|
@apply gap-x-2;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(var(--days), 1fr);
|
||||||
--col: calc(100% / var(--days));
|
--col: calc(100% / var(--days));
|
||||||
|
|
||||||
/* draw a 1px line at the start of each column repeat + highlight weekends */
|
/* draw a 1px line at the start of each column repeat + highlight weekends */
|
||||||
@ -399,6 +444,11 @@
|
|||||||
grid-template-columns: repeat(var(--days), 1fr);
|
grid-template-columns: repeat(var(--days), 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ol.day {
|
||||||
|
@apply gap-x-2;
|
||||||
|
grid-template-columns: repeat(var(--days), 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
ol.events {
|
ol.events {
|
||||||
@apply gap-x-2;
|
@apply gap-x-2;
|
||||||
grid-template-columns: repeat(var(--days), 1fr);
|
grid-template-columns: repeat(var(--days), 1fr);
|
||||||
@ -481,6 +531,14 @@
|
|||||||
.calendar.time {
|
.calendar.time {
|
||||||
grid-template-rows: 4rem auto 5rem;
|
grid-template-rows: 4rem auto 5rem;
|
||||||
|
|
||||||
|
&.allday {
|
||||||
|
grid-template-rows: 4rem min-content auto 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol.day {
|
||||||
|
@apply top-38;
|
||||||
|
}
|
||||||
|
|
||||||
hgroup {
|
hgroup {
|
||||||
div.day-header {
|
div.day-header {
|
||||||
@apply flex-row items-center justify-start gap-2;
|
@apply flex-row items-center justify-start gap-2;
|
||||||
|
|||||||
@ -9,6 +9,16 @@ const SELECTORS = {
|
|||||||
colorPickerColor: '[data-colorpicker-color]',
|
colorPickerColor: '[data-colorpicker-color]',
|
||||||
colorPickerHex: '[data-colorpicker-hex]',
|
colorPickerHex: '[data-colorpicker-hex]',
|
||||||
colorPickerRandom: '[data-colorpicker-random]',
|
colorPickerRandom: '[data-colorpicker-random]',
|
||||||
|
eventAllDayToggle: '[data-all-day-toggle]',
|
||||||
|
eventStartInput: '[data-event-start]',
|
||||||
|
eventEndInput: '[data-event-end]',
|
||||||
|
recurrenceFrequency: '[data-recurrence-frequency]',
|
||||||
|
recurrenceInterval: '[data-recurrence-interval]',
|
||||||
|
recurrenceUnit: '[data-recurrence-unit]',
|
||||||
|
recurrenceSection: '[data-recurrence-section]',
|
||||||
|
monthlyMode: '[data-monthly-mode]',
|
||||||
|
monthlyDays: '[data-monthly-days]',
|
||||||
|
monthlyWeekday: '[data-monthly-weekday]',
|
||||||
monthDay: '.calendar.month .day',
|
monthDay: '.calendar.month .day',
|
||||||
monthDayEvent: 'a.event',
|
monthDayEvent: 'a.event',
|
||||||
monthDayMore: '[data-day-more]',
|
monthDayMore: '[data-day-more]',
|
||||||
@ -108,6 +118,172 @@ window.addEventListener('popstate', () => {
|
|||||||
dialog.close();
|
dialog.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* event form all-day toggle
|
||||||
|
*/
|
||||||
|
function initEventAllDayToggles(root = document) {
|
||||||
|
const toDate = (value) => {
|
||||||
|
if (!value) return '';
|
||||||
|
return value.split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTime = (date, time) => {
|
||||||
|
if (!date) return '';
|
||||||
|
return `${date}T${time}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggle) => {
|
||||||
|
if (toggle.__allDayWired) return;
|
||||||
|
toggle.__allDayWired = true;
|
||||||
|
|
||||||
|
const form = toggle.closest('form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const start = form.querySelector(SELECTORS.eventStartInput);
|
||||||
|
const end = form.querySelector(SELECTORS.eventEndInput);
|
||||||
|
if (!start || !end) return;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
if (toggle.checked) {
|
||||||
|
if (start.type === 'datetime-local') {
|
||||||
|
start.dataset.datetimeValue = start.value;
|
||||||
|
}
|
||||||
|
if (end.type === 'datetime-local') {
|
||||||
|
end.dataset.datetimeValue = end.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = toDate(start.value);
|
||||||
|
const endDate = toDate(end.value);
|
||||||
|
|
||||||
|
start.type = 'date';
|
||||||
|
end.type = 'date';
|
||||||
|
|
||||||
|
if (startDate) start.value = startDate;
|
||||||
|
if (endDate) end.value = endDate;
|
||||||
|
|
||||||
|
if (start.value && end.value && end.value < start.value) {
|
||||||
|
end.value = start.value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const startDate = toDate(start.value);
|
||||||
|
const endDate = toDate(end.value);
|
||||||
|
const startTime = (start.dataset.datetimeValue || '').split('T')[1] || '09:00';
|
||||||
|
const endTime = (end.dataset.datetimeValue || '').split('T')[1] || '10:00';
|
||||||
|
|
||||||
|
start.type = 'datetime-local';
|
||||||
|
end.type = 'datetime-local';
|
||||||
|
|
||||||
|
start.value = start.dataset.datetimeValue || withTime(startDate, startTime);
|
||||||
|
end.value = end.dataset.datetimeValue || withTime(endDate || startDate, endTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.addEventListener('change', apply);
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recurrence preset selector
|
||||||
|
*/
|
||||||
|
function initRecurrenceControls(root = document) {
|
||||||
|
const sections = root.querySelectorAll(SELECTORS.recurrenceSection);
|
||||||
|
if (!sections.length) return;
|
||||||
|
|
||||||
|
const select = root.querySelector(SELECTORS.recurrenceFrequency);
|
||||||
|
const intervalRow = root.querySelector(SELECTORS.recurrenceInterval);
|
||||||
|
const intervalUnit = root.querySelector(SELECTORS.recurrenceUnit);
|
||||||
|
const monthModes = root.querySelectorAll(SELECTORS.monthlyMode);
|
||||||
|
const monthDays = root.querySelector(SELECTORS.monthlyDays);
|
||||||
|
const monthWeekday = root.querySelector(SELECTORS.monthlyWeekday);
|
||||||
|
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const unitMap = {
|
||||||
|
daily: 'day',
|
||||||
|
weekly: 'week',
|
||||||
|
monthly: 'month',
|
||||||
|
yearly: 'year',
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMonthlyMode = () => {
|
||||||
|
if (!monthDays || !monthWeekday) return;
|
||||||
|
const modeInput = Array.from(monthModes).find((input) => input.checked);
|
||||||
|
const mode = modeInput?.value || 'days';
|
||||||
|
|
||||||
|
if (mode === 'weekday') {
|
||||||
|
monthDays.classList.add('hidden');
|
||||||
|
monthWeekday.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
monthDays.classList.remove('hidden');
|
||||||
|
monthWeekday.classList.add('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const value = select.value;
|
||||||
|
const show = value !== '';
|
||||||
|
|
||||||
|
sections.forEach((section) => {
|
||||||
|
const type = section.getAttribute('data-recurrence-section');
|
||||||
|
if (!show) {
|
||||||
|
section.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
section.classList.toggle('hidden', type !== value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (intervalRow) {
|
||||||
|
intervalRow.classList.toggle('hidden', !show);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intervalUnit) {
|
||||||
|
const unit = unitMap[value] || 'day';
|
||||||
|
intervalUnit.textContent = unit ? `${unit}(s)` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'monthly') {
|
||||||
|
applyMonthlyMode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
select.addEventListener('change', apply);
|
||||||
|
monthModes.forEach((input) => input.addEventListener('change', applyMonthlyMode));
|
||||||
|
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* auto-scroll time views to 8am on load (when daytime hours are disabled)
|
||||||
|
*/
|
||||||
|
function initTimeViewAutoScroll(root = document)
|
||||||
|
{
|
||||||
|
// make sure we're on a time calendar
|
||||||
|
const calendar = root.querySelector('.calendar.time');
|
||||||
|
if (!calendar) return;
|
||||||
|
|
||||||
|
// get out if we're autoscrolled or daytime hours is set
|
||||||
|
if (calendar.dataset.autoscrolled === '1') return;
|
||||||
|
if (calendar.dataset.daytimeHoursEnabled === '1') return;
|
||||||
|
|
||||||
|
// find the target minute (7:45am)
|
||||||
|
const target = calendar.querySelector('[data-slot-minutes="465"]');
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
// get the scroll container and offset
|
||||||
|
const container = calendar.closest('article') || document.querySelector('article#calendar');
|
||||||
|
if (!container) return;
|
||||||
|
const header = container.querySelector('header');
|
||||||
|
const headerOffset = header ? header.offsetHeight : 0;
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const targetRect = target.getBoundingClientRect();
|
||||||
|
const top = targetRect.top - containerRect.top + container.scrollTop - headerOffset - 12;
|
||||||
|
|
||||||
|
// scroll
|
||||||
|
container.scrollTo({ top: Math.max(top, 0), behavior: 'auto' });
|
||||||
|
calendar.dataset.autoscrolled = '1';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* calendar sidebar expand toggle
|
* calendar sidebar expand toggle
|
||||||
@ -362,6 +538,9 @@ window.addEventListener('resize', () => {
|
|||||||
|
|
||||||
function initUI() {
|
function initUI() {
|
||||||
initColorPickers();
|
initColorPickers();
|
||||||
|
initEventAllDayToggles();
|
||||||
|
initRecurrenceControls();
|
||||||
|
initTimeViewAutoScroll();
|
||||||
initMonthOverflow();
|
initMonthOverflow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -371,5 +550,8 @@ document.addEventListener('DOMContentLoaded', initUI);
|
|||||||
// rebind in htmx for swapped content
|
// rebind in htmx for swapped content
|
||||||
document.addEventListener('htmx:afterSwap', (e) => {
|
document.addEventListener('htmx:afterSwap', (e) => {
|
||||||
initColorPickers(e.target);
|
initColorPickers(e.target);
|
||||||
|
initEventAllDayToggles(e.target);
|
||||||
|
initRecurrenceControls(e.target);
|
||||||
|
initTimeViewAutoScroll(e.target);
|
||||||
initMonthOverflow(e.target);
|
initMonthOverflow(e.target);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,34 +2,47 @@
|
|||||||
<!-- Session Status -->
|
<!-- Session Status -->
|
||||||
<x-auth-session-status class="mb-4" :status="session('status')" />
|
<x-auth-session-status class="mb-4" :status="session('status')" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('login') }}">
|
<form method="POST" action="{{ route('login') }}" class="auth">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<!-- Email Address -->
|
<!-- Email Address -->
|
||||||
<div>
|
<div class="input-row input-row--1">
|
||||||
<x-input.label for="email" :value="__('Email')" />
|
<div class="input-cell">
|
||||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
<x-input.label for="email" :value="__('Email')" />
|
||||||
<x-input.error :messages="$errors->get('email')" class="mt-2" />
|
<x-input.text
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
:value="old('email')"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="username" />
|
||||||
|
<x-input.error :messages="$errors->get('email')" class="mt-2" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password -->
|
<!-- Password -->
|
||||||
<div class="mt-4">
|
<div class="input-row input-row--1">
|
||||||
<x-input.label for="password" :value="__('Password')" />
|
<div class="input-cell">
|
||||||
|
<x-input.label for="password" :value="__('Password')" />
|
||||||
<x-text-input id="password" class="block mt-1 w-full"
|
<x-input.text
|
||||||
type="password"
|
id="password"
|
||||||
name="password"
|
type="password"
|
||||||
required autocomplete="current-password" />
|
name="password"
|
||||||
|
required
|
||||||
<x-input.error :messages="$errors->get('password')" class="mt-2" />
|
autocomplete="current-password" />
|
||||||
|
<x-input.error :messages="$errors->get('password')" class="mt-2" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remember Me -->
|
<!-- Remember Me -->
|
||||||
<div class="block mt-4">
|
<div class="input-row input-row--1">
|
||||||
<label for="remember_me" class="inline-flex items-center gap-2">
|
<div class="input-cell">
|
||||||
<input id="remember_me" type="checkbox" name="remember">
|
<label for="remember_me" class="inline-flex items-center gap-2">
|
||||||
<span>{{ __('Remember me') }}</span>
|
<input id="remember_me" type="checkbox" name="remember">
|
||||||
</label>
|
<span>{{ __('Remember me') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-4 gap-4">
|
<div class="flex items-center justify-between mt-4 gap-4">
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
@php
|
||||||
|
$eventCalendar = $calendars->firstWhere('is_remote', false) ?? $calendars->first();
|
||||||
|
$eventCalendarSlug = $eventCalendar['slug'] ?? null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
<x-app-layout id="calendar">
|
<x-app-layout id="calendar">
|
||||||
|
|
||||||
<x-slot name="aside">
|
<x-slot name="aside">
|
||||||
@ -177,9 +182,17 @@
|
|||||||
<a class="button button--icon" href="{{ route('calendar.settings') }}">
|
<a class="button button--icon" href="{{ route('calendar.settings') }}">
|
||||||
<x-icon-settings />
|
<x-icon-settings />
|
||||||
</a>
|
</a>
|
||||||
<a class="button button--icon" href="{{ route('calendar.create') }}">
|
@if($eventCalendarSlug)
|
||||||
<x-icon-plus-circle />
|
<a class="button button--icon"
|
||||||
</a>
|
href="{{ route('calendar.event.create', $eventCalendarSlug) }}"
|
||||||
|
hx-get="{{ route('calendar.event.create', $eventCalendarSlug) }}"
|
||||||
|
hx-target="#modal"
|
||||||
|
hx-push-url="false"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
aria-label="{{ __('Create Event') }}">
|
||||||
|
<x-icon-plus-circle />
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
</li>
|
</li>
|
||||||
</menu>
|
</menu>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@ -191,7 +204,9 @@
|
|||||||
class="week time"
|
class="week time"
|
||||||
:grid="$grid"
|
:grid="$grid"
|
||||||
:calendars="$calendars"
|
:calendars="$calendars"
|
||||||
:events="$events"
|
:events="$events_time"
|
||||||
|
:all_day_events="$events_all_day"
|
||||||
|
:has_all_day="$has_all_day"
|
||||||
:slots="$slots"
|
:slots="$slots"
|
||||||
:timeformat="$time_format"
|
:timeformat="$time_format"
|
||||||
:hgroup="$hgroup"
|
:hgroup="$hgroup"
|
||||||
@ -208,7 +223,9 @@
|
|||||||
class="day time"
|
class="day time"
|
||||||
:grid="$grid"
|
:grid="$grid"
|
||||||
:calendars="$calendars"
|
:calendars="$calendars"
|
||||||
:events="$events"
|
:events="$events_time"
|
||||||
|
:all_day_events="$events_all_day"
|
||||||
|
:has_all_day="$has_all_day"
|
||||||
:slots="$slots"
|
:slots="$slots"
|
||||||
:timeformat="$time_format"
|
:timeformat="$time_format"
|
||||||
:hgroup="$hgroup"
|
:hgroup="$hgroup"
|
||||||
@ -224,7 +241,9 @@
|
|||||||
class="four time"
|
class="four time"
|
||||||
:grid="$grid"
|
:grid="$grid"
|
||||||
:calendars="$calendars"
|
:calendars="$calendars"
|
||||||
:events="$events"
|
:events="$events_time"
|
||||||
|
:all_day_events="$events_all_day"
|
||||||
|
:has_all_day="$has_all_day"
|
||||||
:slots="$slots"
|
:slots="$slots"
|
||||||
:timeformat="$time_format"
|
:timeformat="$time_format"
|
||||||
:hgroup="$hgroup"
|
:hgroup="$hgroup"
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
'grid' => [],
|
'grid' => [],
|
||||||
'calendars' => [],
|
'calendars' => [],
|
||||||
'events' => [],
|
'events' => [],
|
||||||
|
'all_day_events' => [],
|
||||||
|
'has_all_day' => false,
|
||||||
'class' => '',
|
'class' => '',
|
||||||
'slots' => [],
|
'slots' => [],
|
||||||
'timeformat' => '',
|
'timeformat' => '',
|
||||||
@ -14,8 +16,16 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
@class(['calendar', $class, 'allday' => $has_all_day ?? false])
|
||||||
style="--now-row: {{ $now['row'] }}; --now-offset: {{ $now['offset'] ?? 0 }}; --now-col-start: {{ $now['col_start'] }}; --now-col-end: {{ $now['col_end'] }}; --grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
|
data-density="{{ $density['step'] }}"
|
||||||
|
data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}"
|
||||||
|
style="
|
||||||
|
--now-row: {{ $now['row'] }};
|
||||||
|
--now-offset: {{ $now['offset'] ?? 0 }};
|
||||||
|
--now-col-start: {{ $now['col_start'] }};
|
||||||
|
--now-col-end: {{ $now['col_end'] }};
|
||||||
|
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
@foreach ($hgroup as $h)
|
@foreach ($hgroup as $h)
|
||||||
@ -25,10 +35,17 @@
|
|||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</hgroup>
|
</hgroup>
|
||||||
|
@if($has_all_day)
|
||||||
|
<ol class="day" aria-label="{{ __('calendar.event.all_day_events') }}">
|
||||||
|
@foreach ($all_day_events as $event)
|
||||||
|
<x-calendar.time.day-event :event="$event" />
|
||||||
|
@endforeach
|
||||||
|
</ol>
|
||||||
|
@endif
|
||||||
<ol class="time" aria-label="{{ __('Times') }}">
|
<ol class="time" aria-label="{{ __('Times') }}">
|
||||||
@foreach ($slots as $slot)
|
@foreach ($slots as $slot)
|
||||||
<li>
|
<li>
|
||||||
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
|
<time datetime="{{ $slot['iso'] }}" data-slot-minutes="{{ $slot['minutes'] }}">{{ $slot['label'] }}</time>
|
||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
'grid' => [],
|
'grid' => [],
|
||||||
'calendars' => [],
|
'calendars' => [],
|
||||||
'events' => [],
|
'events' => [],
|
||||||
|
'all_day_events' => [],
|
||||||
|
'has_all_day' => false,
|
||||||
'class' => '',
|
'class' => '',
|
||||||
'slots' => [],
|
'slots' => [],
|
||||||
'timeformat' => '',
|
'timeformat' => '',
|
||||||
@ -14,13 +16,16 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
@class(['calendar', $class, 'allday' => $has_all_day ?? false])
|
||||||
style=
|
data-density="{{ $density['step'] }}"
|
||||||
"--now-row: {{ (int) $now['row'] }};
|
data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}"
|
||||||
|
style="
|
||||||
|
--now-row: {{ (int) $now['row'] }};
|
||||||
--now-offset: {{ $now['offset'] ?? 0 }};
|
--now-offset: {{ $now['offset'] ?? 0 }};
|
||||||
--now-col-start: {{ (int) $now['col_start'] }};
|
--now-col-start: {{ (int) $now['col_start'] }};
|
||||||
--now-col-end: {{ (int) $now['col_end'] }};
|
--now-col-end: {{ (int) $now['col_end'] }};
|
||||||
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
|
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
@foreach ($hgroup as $h)
|
@foreach ($hgroup as $h)
|
||||||
@ -45,12 +50,19 @@
|
|||||||
{{ $h['day'] }}
|
{{ $h['day'] }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</hgroup>
|
</hgroup>
|
||||||
|
@if($has_all_day)
|
||||||
|
<ol class="day" aria-label="{{ __('calendar.event.all_day_events') }}">
|
||||||
|
@foreach ($all_day_events as $event)
|
||||||
|
<x-calendar.time.day-event :event="$event" />
|
||||||
|
@endforeach
|
||||||
|
</ol>
|
||||||
|
@endif
|
||||||
<ol class="time" aria-label="{{ __('Times') }}">
|
<ol class="time" aria-label="{{ __('Times') }}">
|
||||||
@foreach ($slots as $slot)
|
@foreach ($slots as $slot)
|
||||||
<li>
|
<li>
|
||||||
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
|
<time datetime="{{ $slot['iso'] }}" data-slot-minutes="{{ $slot['minutes'] }}">{{ $slot['label'] }}</time>
|
||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
27
resources/views/components/calendar/time/day-event.blade.php
Normal file
27
resources/views/components/calendar/time/day-event.blade.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
@props([
|
||||||
|
'event' => [],
|
||||||
|
])
|
||||||
|
|
||||||
|
<li class="events"
|
||||||
|
data-event-id="{{ $event['occurrence_id'] ?? $event['id'] }}"
|
||||||
|
data-calendar-id="{{ $event['calendar_slug'] }}"
|
||||||
|
style="
|
||||||
|
--event-col: {{ $event['start_col'] ?? 1 }};
|
||||||
|
--event-bg: {{ $event['color'] }};
|
||||||
|
--event-fg: {{ $event['color_fg'] }};">
|
||||||
|
@php
|
||||||
|
$showParams = [$event['calendar_slug'], $event['id']];
|
||||||
|
if (!empty($event['occurrence'])) {
|
||||||
|
$showParams['occurrence'] = $event['occurrence'];
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
|
||||||
|
href="{{ route('calendar.event.show', $showParams) }}"
|
||||||
|
hx-get="{{ route('calendar.event.show', $showParams) }}"
|
||||||
|
hx-target="#modal"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
data-calendar="{{ $event['calendar_slug'] }}">
|
||||||
|
<span>{{ $event['title'] }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
@ -2,6 +2,8 @@
|
|||||||
'grid' => [],
|
'grid' => [],
|
||||||
'calendars' => [],
|
'calendars' => [],
|
||||||
'events' => [],
|
'events' => [],
|
||||||
|
'all_day_events' => [],
|
||||||
|
'has_all_day' => false,
|
||||||
'class' => '',
|
'class' => '',
|
||||||
'slots' => [],
|
'slots' => [],
|
||||||
'timeformat' => '',
|
'timeformat' => '',
|
||||||
@ -15,13 +17,17 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}" data-weekstart="{{ $weekstart }}"
|
@class(['calendar', $class, 'allday' => $has_all_day ?? false])
|
||||||
style=
|
data-density="{{ $density['step'] }}"
|
||||||
"--now-row: {{ (int) $now['row'] }};
|
data-weekstart="{{ $weekstart }}"
|
||||||
|
data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}"
|
||||||
|
style="
|
||||||
|
--now-row: {{ (int) $now['row'] }};
|
||||||
--now-offset: {{ $now['offset'] ?? 0 }};
|
--now-offset: {{ $now['offset'] ?? 0 }};
|
||||||
--now-col-start: {{ (int) $now['col_start'] }};
|
--now-col-start: {{ (int) $now['col_start'] }};
|
||||||
--now-col-end: {{ (int) $now['col_end'] }};
|
--now-col-end: {{ (int) $now['col_end'] }};
|
||||||
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
|
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
@foreach ($hgroup as $h)
|
@foreach ($hgroup as $h)
|
||||||
@ -46,12 +52,19 @@
|
|||||||
{{ $h['day'] }}
|
{{ $h['day'] }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</hgroup>
|
</hgroup>
|
||||||
|
@if($has_all_day)
|
||||||
|
<ol class="day" aria-label="{{ __('calendar.event.all_day_events') }}">
|
||||||
|
@foreach ($all_day_events as $event)
|
||||||
|
<x-calendar.time.day-event :event="$event" />
|
||||||
|
@endforeach
|
||||||
|
</ol>
|
||||||
|
@endif
|
||||||
<ol class="time" aria-label="{{ __('Times') }}">
|
<ol class="time" aria-label="{{ __('Times') }}">
|
||||||
@foreach ($slots as $slot)
|
@foreach ($slots as $slot)
|
||||||
<li>
|
<li>
|
||||||
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
|
<time datetime="{{ $slot['iso'] }}" data-slot-minutes="{{ $slot['minutes'] }}">{{ $slot['label'] }}</time>
|
||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150']) }}>
|
|
||||||
{{ $slot }}
|
|
||||||
</button>
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
@props(['value'])
|
|
||||||
|
|
||||||
<label {{ $attributes->merge(['class' => 'block font-medium text-sm text-gray-700']) }}>
|
|
||||||
{{ $value ?? $slot }}
|
|
||||||
</label>
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150']) }}>
|
|
||||||
{{ $slot }}
|
|
||||||
</button>
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest shadow-xs hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 transition ease-in-out duration-150']) }}>
|
|
||||||
{{ $slot }}
|
|
||||||
</button>
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
@props(['disabled' => false])
|
|
||||||
|
|
||||||
<input @disabled($disabled) {{ $attributes->merge(['class' => '']) }}>
|
|
||||||
@ -16,124 +16,7 @@
|
|||||||
<div class="py-6">
|
<div class="py-6">
|
||||||
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
<div class="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||||
<div class="bg-white shadow-sm sm:rounded-lg p-6">
|
<div class="bg-white shadow-sm sm:rounded-lg p-6">
|
||||||
<form method="POST"
|
@include('event.partials.form', compact('calendar', 'event', 'start', 'end', 'rrule'))
|
||||||
action="{{ $event->exists
|
|
||||||
? route('calendar.event.update', [$calendar, $event])
|
|
||||||
: route('calendar.event.store', $calendar) }}">
|
|
||||||
|
|
||||||
@csrf
|
|
||||||
@if($event->exists)
|
|
||||||
@method('PUT')
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Title --}}
|
|
||||||
<div class="mb-6">
|
|
||||||
<x-input-label for="title" :value="__('Title')" />
|
|
||||||
<x-text-input id="title" name="title" type="text" class="mt-1 block w-full"
|
|
||||||
:value="old('title', $event->meta?->title ?? '')" required autofocus />
|
|
||||||
<x-input.error class="mt-2" :messages="$errors->get('title')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Description --}}
|
|
||||||
<div class="mb-6">
|
|
||||||
<x-input-label for="description" :value="__('Description')" />
|
|
||||||
<textarea id="description" name="description" rows="3"
|
|
||||||
class="mt-1 block w-full rounded-md shadow-xs border-gray-300 focus:border-indigo-300 focus:ring-3">{{ old('description', $event->meta?->description ?? '') }}</textarea>
|
|
||||||
<x-input.error class="mt-2" :messages="$errors->get('description')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Location --}}
|
|
||||||
<div class="mb-6">
|
|
||||||
<x-input-label for="location" :value="__('Location')" />
|
|
||||||
<x-text-input id="location"
|
|
||||||
name="location"
|
|
||||||
type="text"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
:value="old('location', $event->meta?->location ?? '')"
|
|
||||||
{{-- live suggestions via htmx --}}
|
|
||||||
hx-get="{{ route('location.suggest') }}"
|
|
||||||
hx-trigger="keyup changed delay:300ms"
|
|
||||||
hx-target="#location-suggestions"
|
|
||||||
hx-swap="innerHTML" />
|
|
||||||
|
|
||||||
{{-- suggestion dropdown target --}}
|
|
||||||
<div id="location-suggestions" class="relative z-20"></div>
|
|
||||||
|
|
||||||
{{-- hidden fields (filled when user clicks a suggestion; handy for step #2) --}}
|
|
||||||
<input type="hidden" id="loc_display_name" name="loc_display_name" />
|
|
||||||
<input type="hidden" id="loc_place_name" name="loc_place_name" />
|
|
||||||
<input type="hidden" id="loc_street" name="loc_street" />
|
|
||||||
<input type="hidden" id="loc_city" name="loc_city" />
|
|
||||||
<input type="hidden" id="loc_state" name="loc_state" />
|
|
||||||
<input type="hidden" id="loc_postal" name="loc_postal" />
|
|
||||||
<input type="hidden" id="loc_country" name="loc_country" />
|
|
||||||
<input type="hidden" id="loc_lat" name="loc_lat" />
|
|
||||||
<input type="hidden" id="loc_lon" name="loc_lon" />
|
|
||||||
|
|
||||||
<x-input.error class="mt-2" :messages="$errors->get('location')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Start / End --}}
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-input-label for="start_at" :value="__('Starts')" />
|
|
||||||
<x-text-input id="start_at" name="start_at" type="datetime-local"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
:value="old('start_at', $start)"
|
|
||||||
required />
|
|
||||||
<x-input.error class="mt-2" :messages="$errors->get('start_at')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-input-label for="end_at" :value="__('Ends')" />
|
|
||||||
<x-text-input id="end_at" name="end_at" type="datetime-local"
|
|
||||||
class="mt-1 block w-full"
|
|
||||||
:value="old('end_at', $end)"
|
|
||||||
required />
|
|
||||||
<x-input.error class="mt-2" :messages="$errors->get('end_at')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- All-day --}}
|
|
||||||
<div class="flex items-center mb-6">
|
|
||||||
<input id="all_day" name="all_day" type="checkbox" value="1"
|
|
||||||
@checked(old('all_day', $event->meta?->all_day)) />
|
|
||||||
<label for="all_day" class="ms-2 text-sm text-gray-700">
|
|
||||||
{{ __('All day event') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Recurrence (advanced) --}}
|
|
||||||
<details class="mb-6">
|
|
||||||
<summary class="cursor-pointer text-sm text-gray-600">
|
|
||||||
{{ __('Repeat (advanced)') }}
|
|
||||||
</summary>
|
|
||||||
<div class="mt-3">
|
|
||||||
<x-input-label for="rrule" :value="__('RRULE')" />
|
|
||||||
<x-text-input id="rrule" name="rrule" type="text" class="mt-1 block w-full"
|
|
||||||
:value="old('rrule', $rrule ?? '')" />
|
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
|
||||||
Example: <code>FREQ=WEEKLY;BYDAY=MO,WE</code>
|
|
||||||
</p>
|
|
||||||
<x-input.error class="mt-2" :messages="$errors->get('rrule')" />
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
{{-- Submit --}}
|
|
||||||
<div class="flex justify-end space-x-2">
|
|
||||||
<a href="{{ route('calendar.show', $calendar) }}"
|
|
||||||
class="inline-flex items-center px-4 py-2 bg-gray-200 rounded-md">
|
|
||||||
{{ __('Cancel') }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<x-primary-button>
|
|
||||||
{{ $event->exists ? __('Save') : __('Create') }}
|
|
||||||
</x-primary-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
resources/views/event/partials/form-modal.blade.php
Normal file
23
resources/views/event/partials/form-modal.blade.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<x-modal.content>
|
||||||
|
<x-modal.title>
|
||||||
|
<h2>{{ $event->exists ? __('Edit Event') : __('Create Event') }}</h2>
|
||||||
|
</x-modal.title>
|
||||||
|
<x-modal.body>
|
||||||
|
@include('event.partials.form', [
|
||||||
|
'calendar' => $calendar,
|
||||||
|
'event' => $event,
|
||||||
|
'start' => $start,
|
||||||
|
'end' => $end,
|
||||||
|
'rrule' => $rrule,
|
||||||
|
'isModal' => true,
|
||||||
|
])
|
||||||
|
</x-modal.body>
|
||||||
|
<x-modal.footer>
|
||||||
|
<x-button variant="secondary" onclick="this.closest('dialog')?.close()">
|
||||||
|
{{ __('common.cancel') }}
|
||||||
|
</x-button>
|
||||||
|
<x-button variant="primary" type="submit" form="event-form">
|
||||||
|
{{ $event->exists ? __('Save') : __('Create') }}
|
||||||
|
</x-button>
|
||||||
|
</x-modal.footer>
|
||||||
|
</x-modal.content>
|
||||||
319
resources/views/event/partials/form.blade.php
Normal file
319
resources/views/event/partials/form.blade.php
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
@php
|
||||||
|
$isModal = $isModal ?? false;
|
||||||
|
$formAction = $event->exists
|
||||||
|
? route('calendar.event.update', [$calendar, $event])
|
||||||
|
: route('calendar.event.store', $calendar);
|
||||||
|
$rruleValue = trim((string) ($rrule ?? ''));
|
||||||
|
$rruleParts = [];
|
||||||
|
foreach (array_filter(explode(';', $rruleValue)) as $chunk) {
|
||||||
|
if (!str_contains($chunk, '=')) continue;
|
||||||
|
[$key, $value] = explode('=', $chunk, 2);
|
||||||
|
$rruleParts[strtoupper($key)] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$freq = strtolower($rruleParts['FREQ'] ?? '');
|
||||||
|
$interval = (int) ($rruleParts['INTERVAL'] ?? 1);
|
||||||
|
if ($interval < 1) $interval = 1;
|
||||||
|
|
||||||
|
$byday = array_filter(explode(',', $rruleParts['BYDAY'] ?? ''));
|
||||||
|
$bymonthday = array_filter(explode(',', $rruleParts['BYMONTHDAY'] ?? ''));
|
||||||
|
$bysetpos = $rruleParts['BYSETPOS'] ?? null;
|
||||||
|
|
||||||
|
$startDefault = old('start_at', $start ?? null);
|
||||||
|
$startDate = $startDefault ? \Carbon\Carbon::parse($startDefault) : \Carbon\Carbon::now();
|
||||||
|
$weekdayMap = [
|
||||||
|
'Sun' => 'SU',
|
||||||
|
'Mon' => 'MO',
|
||||||
|
'Tue' => 'TU',
|
||||||
|
'Wed' => 'WE',
|
||||||
|
'Thu' => 'TH',
|
||||||
|
'Fri' => 'FR',
|
||||||
|
'Sat' => 'SA',
|
||||||
|
];
|
||||||
|
$defaultWeekday = $weekdayMap[$startDate->format('D')] ?? 'MO';
|
||||||
|
$defaultMonthDay = (int) $startDate->format('j');
|
||||||
|
|
||||||
|
$weekMap = [1 => 'first', 2 => 'second', 3 => 'third', 4 => 'fourth'];
|
||||||
|
$startWeek = $startDate->copy();
|
||||||
|
$isLastWeek = $startWeek->copy()->addWeek()->month !== $startWeek->month;
|
||||||
|
$defaultMonthWeek = $isLastWeek ? 'last' : ($weekMap[$startDate->weekOfMonth] ?? 'first');
|
||||||
|
|
||||||
|
$monthMode = 'days';
|
||||||
|
if (!empty($bymonthday)) {
|
||||||
|
$monthMode = 'days';
|
||||||
|
} elseif (!empty($byday) && $bysetpos) {
|
||||||
|
$monthMode = 'weekday';
|
||||||
|
}
|
||||||
|
|
||||||
|
$repeatFrequency = old('repeat_frequency', $freq ?: '');
|
||||||
|
$repeatInterval = old('repeat_interval', $interval);
|
||||||
|
$repeatWeekdays = old('repeat_weekdays', $byday ?: [$defaultWeekday]);
|
||||||
|
$repeatMonthDays = old('repeat_month_days', $bymonthday ?: [$defaultMonthDay]);
|
||||||
|
$repeatMonthMode = old('repeat_monthly_mode', $monthMode);
|
||||||
|
|
||||||
|
$setposMap = ['1' => 'first', '2' => 'second', '3' => 'third', '4' => 'fourth', '-1' => 'last'];
|
||||||
|
$repeatMonthWeek = old('repeat_month_week', $setposMap[(string) $bysetpos] ?? $defaultMonthWeek);
|
||||||
|
$repeatMonthWeekday = old('repeat_month_weekday', $byday[0] ?? $defaultWeekday);
|
||||||
|
|
||||||
|
$rruleOptions = [
|
||||||
|
'daily' => __('calendar.event.recurrence.daily'),
|
||||||
|
'weekly' => __('calendar.event.recurrence.weekly'),
|
||||||
|
'monthly' => __('calendar.event.recurrence.monthly'),
|
||||||
|
'yearly' => __('calendar.event.recurrence.yearly'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$weekdayOptions = [
|
||||||
|
'SU' => __('calendar.event.recurrence.weekdays.sun_short'),
|
||||||
|
'MO' => __('calendar.event.recurrence.weekdays.mon_short'),
|
||||||
|
'TU' => __('calendar.event.recurrence.weekdays.tue_short'),
|
||||||
|
'WE' => __('calendar.event.recurrence.weekdays.wed_short'),
|
||||||
|
'TH' => __('calendar.event.recurrence.weekdays.thu_short'),
|
||||||
|
'FR' => __('calendar.event.recurrence.weekdays.fri_short'),
|
||||||
|
'SA' => __('calendar.event.recurrence.weekdays.sat_short'),
|
||||||
|
];
|
||||||
|
$weekdayLong = [
|
||||||
|
'SU' => __('calendar.event.recurrence.weekdays.sun'),
|
||||||
|
'MO' => __('calendar.event.recurrence.weekdays.mon'),
|
||||||
|
'TU' => __('calendar.event.recurrence.weekdays.tue'),
|
||||||
|
'WE' => __('calendar.event.recurrence.weekdays.wed'),
|
||||||
|
'TH' => __('calendar.event.recurrence.weekdays.thu'),
|
||||||
|
'FR' => __('calendar.event.recurrence.weekdays.fri'),
|
||||||
|
'SA' => __('calendar.event.recurrence.weekdays.sat'),
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<form method="POST" id="event-form" action="{{ $formAction }}" class="settings modal">
|
||||||
|
@csrf
|
||||||
|
@if($event->exists)
|
||||||
|
@method('PUT')
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- Title --}}
|
||||||
|
<div class="input-row input-row--1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.label for="title" :value="__('Title')" />
|
||||||
|
<x-input.text
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
:value="old('title', $event->meta?->title ?? '')"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<x-input.error class="mt-2" :messages="$errors->get('title')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Description --}}
|
||||||
|
<div class="input-row input-row--1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.label for="description" :value="__('Description')" />
|
||||||
|
<x-input.textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="3">{{ old('description', $event->meta?->description ?? '') }}</x-input.textarea>
|
||||||
|
<x-input.error :messages="$errors->get('description')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Location --}}
|
||||||
|
<div class="input-row input-row--1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.label for="location" :value="__('Location')" />
|
||||||
|
<x-input.text
|
||||||
|
id="location"
|
||||||
|
name="location"
|
||||||
|
:value="old('location', $event->meta?->location ?? '')"
|
||||||
|
{{-- live suggestions via htmx --}}
|
||||||
|
hx-get="{{ route('location.suggest') }}"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#location-suggestions"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
/>
|
||||||
|
<x-input.error :messages="$errors->get('location')" />
|
||||||
|
|
||||||
|
{{-- suggestion dropdown target --}}
|
||||||
|
<div id="location-suggestions" class="relative z-20"></div>
|
||||||
|
{{-- hidden fields (filled when user clicks a suggestion; handy for step #2) --}}
|
||||||
|
<input type="hidden" id="loc_display_name" name="loc_display_name" />
|
||||||
|
<input type="hidden" id="loc_place_name" name="loc_place_name" />
|
||||||
|
<input type="hidden" id="loc_street" name="loc_street" />
|
||||||
|
<input type="hidden" id="loc_city" name="loc_city" />
|
||||||
|
<input type="hidden" id="loc_state" name="loc_state" />
|
||||||
|
<input type="hidden" id="loc_postal" name="loc_postal" />
|
||||||
|
<input type="hidden" id="loc_country" name="loc_country" />
|
||||||
|
<input type="hidden" id="loc_lat" name="loc_lat" />
|
||||||
|
<input type="hidden" id="loc_lon" name="loc_lon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Start / End --}}
|
||||||
|
<div class="input-row input-row--1-1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.label for="start_at" :value="__('Starts')" />
|
||||||
|
<x-input.text
|
||||||
|
id="start_at"
|
||||||
|
name="start_at"
|
||||||
|
type="datetime-local"
|
||||||
|
:value="old('start_at', $start)"
|
||||||
|
data-event-start
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<x-input.error :messages="$errors->get('start_at')" />
|
||||||
|
</div>
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.label for="end_at" :value="__('Ends')" />
|
||||||
|
<x-input.text
|
||||||
|
id="end_at"
|
||||||
|
name="end_at"
|
||||||
|
type="datetime-local"
|
||||||
|
:value="old('end_at', $end)"
|
||||||
|
data-event-end
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<x-input.error :messages="$errors->get('end_at')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- All-day --}}
|
||||||
|
<div class="input-row input-row--1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.checkbox-label
|
||||||
|
label="{{ __('All day event') }}"
|
||||||
|
id="all_day"
|
||||||
|
name="all_day"
|
||||||
|
value="1"
|
||||||
|
data-all-day-toggle
|
||||||
|
:checked="(bool) old('all_day', $event->meta?->all_day)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Recurrence (advanced) --}}
|
||||||
|
<div class="input-row input-row--1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<details>
|
||||||
|
<summary class="cursor-pointer text-sm text-gray-600">
|
||||||
|
{{ __('calendar.event.recurrence.label') }}
|
||||||
|
</summary>
|
||||||
|
<div class="mt-3">
|
||||||
|
<x-input.label for="repeat_frequency" :value="__('calendar.event.recurrence.frequency')" />
|
||||||
|
<x-input.select
|
||||||
|
id="repeat_frequency"
|
||||||
|
name="repeat_frequency"
|
||||||
|
:options="$rruleOptions"
|
||||||
|
:selected="$repeatFrequency"
|
||||||
|
:placeholder="__('calendar.event.recurrence.none')"
|
||||||
|
data-recurrence-frequency
|
||||||
|
/>
|
||||||
|
<x-input.error :messages="$errors->get('repeat_frequency')" />
|
||||||
|
|
||||||
|
<div class="mt-3 {{ $repeatFrequency === '' ? 'hidden' : '' }}" data-recurrence-interval>
|
||||||
|
<x-input.label for="repeat_interval" :value="__('calendar.event.recurrence.every')" />
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<x-input.text
|
||||||
|
id="repeat_interval"
|
||||||
|
name="repeat_interval"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
:value="$repeatInterval"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-600" data-recurrence-unit></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 {{ $repeatFrequency !== 'weekly' ? 'hidden' : '' }}" data-recurrence-section="weekly">
|
||||||
|
<p class="text-sm text-gray-600">{{ __('calendar.event.recurrence.on_days') }}</p>
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
|
@foreach ($weekdayOptions as $code => $label)
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm">
|
||||||
|
<x-input.checkbox
|
||||||
|
name="repeat_weekdays[]"
|
||||||
|
value="{{ $code }}"
|
||||||
|
:checked="in_array($code, (array) $repeatWeekdays, true)"
|
||||||
|
/>
|
||||||
|
<span title="{{ $weekdayLong[$code] ?? $code }}">{{ $label }}</span>
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 {{ $repeatFrequency !== 'monthly' ? 'hidden' : '' }}" data-recurrence-section="monthly">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<x-input.radio-label
|
||||||
|
id="repeat_monthly_mode_days"
|
||||||
|
name="repeat_monthly_mode"
|
||||||
|
value="days"
|
||||||
|
label="{{ __('calendar.event.recurrence.on_days') }}"
|
||||||
|
:checked="$repeatMonthMode === 'days'"
|
||||||
|
data-monthly-mode
|
||||||
|
/>
|
||||||
|
<x-input.radio-label
|
||||||
|
id="repeat_monthly_mode_weekday"
|
||||||
|
name="repeat_monthly_mode"
|
||||||
|
value="weekday"
|
||||||
|
label="{{ __('calendar.event.recurrence.on_the') }}"
|
||||||
|
:checked="$repeatMonthMode === 'weekday'"
|
||||||
|
data-monthly-mode
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-7 gap-2 {{ $repeatMonthMode !== 'days' ? 'hidden' : '' }}" data-monthly-days>
|
||||||
|
@for ($day = 1; $day <= 31; $day++)
|
||||||
|
<label class="inline-flex items-center gap-2 text-xs">
|
||||||
|
<x-input.checkbox
|
||||||
|
name="repeat_month_days[]"
|
||||||
|
value="{{ $day }}"
|
||||||
|
:checked="in_array($day, (array) $repeatMonthDays)"
|
||||||
|
/>
|
||||||
|
<span>{{ $day }}</span>
|
||||||
|
</label>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 {{ $repeatMonthMode !== 'weekday' ? 'hidden' : '' }}" data-monthly-weekday>
|
||||||
|
<x-input.select
|
||||||
|
id="repeat_month_week"
|
||||||
|
name="repeat_month_week"
|
||||||
|
:options="[
|
||||||
|
'first' => __('calendar.event.recurrence.week_order.first'),
|
||||||
|
'second' => __('calendar.event.recurrence.week_order.second'),
|
||||||
|
'third' => __('calendar.event.recurrence.week_order.third'),
|
||||||
|
'fourth' => __('calendar.event.recurrence.week_order.fourth'),
|
||||||
|
'last' => __('calendar.event.recurrence.week_order.last'),
|
||||||
|
]"
|
||||||
|
:selected="$repeatMonthWeek"
|
||||||
|
/>
|
||||||
|
<x-input.select
|
||||||
|
id="repeat_month_weekday"
|
||||||
|
name="repeat_month_weekday"
|
||||||
|
:options="$weekdayLong"
|
||||||
|
:selected="$repeatMonthWeekday"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-sm text-gray-600 {{ $repeatFrequency !== 'yearly' ? 'hidden' : '' }}" data-recurrence-section="yearly">
|
||||||
|
{{ __('calendar.event.recurrence.yearly_hint') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Submit --}}
|
||||||
|
@if(!$isModal)
|
||||||
|
<div class="input-row input-row--actions input-row--start sticky-bottom">
|
||||||
|
<x-button type="anchor" variant="tertiary" href="{{ route('calendar.show', $calendar) }}">
|
||||||
|
{{ __('common.cancel') }}
|
||||||
|
</x-button>
|
||||||
|
<x-button variant="primary" type="submit">
|
||||||
|
{{ $event->exists ? __('Save') : __('Create') }}
|
||||||
|
</x-button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</form>
|
||||||
Loading…
Reference in New Issue
Block a user