Compare commits
2 Commits
ef658d1c04
...
236da90af2
| Author | SHA1 | Date | |
|---|---|---|---|
| 236da90af2 | |||
| baeb291db5 |
@ -24,6 +24,10 @@ GEOCODER_COUNTRY=USA
|
||||
GEOCODER_CATEGORIES=POI,Address
|
||||
ARCGIS_API_KEY=
|
||||
ARCGIS_STORE_RESULTS=true # set to false to not store results
|
||||
ARCGIS_DEBUG=false
|
||||
ARCGIS_BASEMAP_STYLE=arcgis/community
|
||||
ARCGIS_BASEMAP_ZOOM=16
|
||||
GEOCODE_AFTER_MIGRATE=false
|
||||
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ use App\Services\Calendar\CalendarRangeResolver;
|
||||
use App\Services\Calendar\CalendarViewBuilder;
|
||||
use App\Services\Calendar\CalendarSettingsPersister;
|
||||
use App\Services\Event\EventRecurrence;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CalendarController extends Controller
|
||||
{
|
||||
@ -137,6 +138,96 @@ class CalendarController extends Controller
|
||||
$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
|
||||
@ -205,6 +296,9 @@ class CalendarController extends Controller
|
||||
'week_start' => $weekStart,
|
||||
'hgroup' => $viewBuilder->viewHeaders($view, $range, $tz, $weekStart),
|
||||
'events' => $events, // keyed by occurrence
|
||||
'events_time' => $timeEvents,
|
||||
'events_all_day'=> $allDayEvents,
|
||||
'has_all_day' => $hasAllDayEvents,
|
||||
'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
|
||||
|
||||
@ -49,7 +49,7 @@ class EventController extends Controller
|
||||
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
|
||||
$rrule = '';
|
||||
|
||||
return view('event.form', compact(
|
||||
$data = compact(
|
||||
'calendar',
|
||||
'instance',
|
||||
'event',
|
||||
@ -57,7 +57,13 @@ class EventController extends Controller
|
||||
'end',
|
||||
'tz',
|
||||
'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)
|
||||
?? '';
|
||||
|
||||
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);
|
||||
$end = $endUtc->copy()->timezone($tz);
|
||||
|
||||
if ($event->meta?->all_day && $end->gt($start)) {
|
||||
$end = $end->copy()->subDay();
|
||||
}
|
||||
|
||||
$map = $this->buildBasemapTiles($event->meta?->venue);
|
||||
|
||||
$event->setAttribute('color', $calendarColor);
|
||||
@ -168,15 +184,32 @@ class EventController extends Controller
|
||||
{
|
||||
$this->authorize('update', $calendar);
|
||||
|
||||
$data = $request->validate([
|
||||
$this->normalizeRecurrenceInputs($request);
|
||||
|
||||
$rules = [
|
||||
'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'],
|
||||
'location' => ['nullable', 'string'],
|
||||
'all_day' => ['sometimes', 'boolean'],
|
||||
'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)
|
||||
'loc_display_name' => ['nullable', 'string'],
|
||||
@ -188,23 +221,47 @@ class EventController extends Controller
|
||||
'loc_country' => ['nullable', 'string'],
|
||||
'loc_lat' => ['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);
|
||||
$isAllDay = (bool) ($data['all_day'] ?? false);
|
||||
|
||||
// parse input in display tz, store in utc
|
||||
if ($isAllDay) {
|
||||
[$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);
|
||||
|
||||
$rrule = $this->normalizeRrule($request);
|
||||
$rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay));
|
||||
$extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
|
||||
|
||||
$ical = $recurrence->buildCalendar([
|
||||
'uid' => $uid,
|
||||
'start_utc' => $startUtc,
|
||||
'end_utc' => $endUtc,
|
||||
'all_day' => $isAllDay,
|
||||
'start_date' => $startOn,
|
||||
'end_date' => $endOn,
|
||||
'summary' => $data['title'],
|
||||
'description' => $data['description'] ?? '',
|
||||
'location' => $data['location'] ?? '',
|
||||
@ -230,10 +287,13 @@ class EventController extends Controller
|
||||
'description' => $data['description'] ?? null,
|
||||
'location' => $data['location'] ?? null,
|
||||
'location_id' => $locationId,
|
||||
'all_day' => (bool) ($data['all_day'] ?? false),
|
||||
'all_day' => $isAllDay,
|
||||
'category' => $data['category'] ?? null,
|
||||
'start_at' => $startUtc,
|
||||
'end_at' => $endUtc,
|
||||
'start_on' => $startOn,
|
||||
'end_on' => $endOn,
|
||||
'tzid' => $isAllDay ? $tz : null,
|
||||
'extra' => $extra,
|
||||
]);
|
||||
|
||||
@ -251,25 +311,63 @@ class EventController extends Controller
|
||||
abort(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
$this->normalizeRecurrenceInputs($request);
|
||||
|
||||
$rules = [
|
||||
'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'],
|
||||
'location' => ['nullable', 'string'],
|
||||
'all_day' => ['sometimes', 'boolean'],
|
||||
'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);
|
||||
$isAllDay = (bool) ($data['all_day'] ?? false);
|
||||
|
||||
if ($isAllDay) {
|
||||
[$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;
|
||||
|
||||
$rrule = $this->normalizeRrule($request);
|
||||
$rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay));
|
||||
$extra = $event->meta?->extra ?? [];
|
||||
$extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request);
|
||||
$rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event));
|
||||
@ -278,6 +376,9 @@ class EventController extends Controller
|
||||
'uid' => $uid,
|
||||
'start_utc' => $startUtc,
|
||||
'end_utc' => $endUtc,
|
||||
'all_day' => $isAllDay,
|
||||
'start_date' => $startOn,
|
||||
'end_date' => $endOn,
|
||||
'summary' => $data['title'],
|
||||
'description' => $data['description'] ?? '',
|
||||
'location' => $data['location'] ?? '',
|
||||
@ -297,10 +398,13 @@ class EventController extends Controller
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'location' => $data['location'] ?? null,
|
||||
'all_day' => (bool) ($data['all_day'] ?? false),
|
||||
'all_day' => $isAllDay,
|
||||
'category' => $data['category'] ?? null,
|
||||
'start_at' => $startUtc,
|
||||
'end_at' => $endUtc,
|
||||
'start_on' => $startOn,
|
||||
'end_on' => $endOn,
|
||||
'tzid' => $isAllDay ? $tz : null,
|
||||
'extra' => $extra,
|
||||
]);
|
||||
|
||||
@ -328,6 +432,40 @@ class EventController extends Controller
|
||||
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
|
||||
*/
|
||||
@ -338,14 +476,89 @@ class EventController extends Controller
|
||||
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;
|
||||
}
|
||||
|
||||
$rrule = trim((string) $request->input('rrule'));
|
||||
return $rrule === '' ? '' : $rrule;
|
||||
$freq = strtolower((string) $request->input('repeat_frequency', ''));
|
||||
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
|
||||
|
||||
@ -60,19 +60,26 @@ class IcsController extends Controller
|
||||
}
|
||||
|
||||
$meta = $event->meta;
|
||||
if (!$meta || !$meta->start_at || !$meta->end_at) {
|
||||
if (!$meta) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = Carbon::parse($meta->start_at)->timezone($tz);
|
||||
$end = Carbon::parse($meta->end_at)->timezone($tz);
|
||||
|
||||
$vevent = $vcalendar->add('VEVENT', []);
|
||||
$vevent->add('UID', $event->uid);
|
||||
$vevent->add('SUMMARY', $meta->title ?? '(Untitled)');
|
||||
$vevent->add('DESCRIPTION', $meta->description ?? '');
|
||||
|
||||
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) {
|
||||
$vevent->add('DTSTAMP', Carbon::createFromTimestamp($event->lastmodified)->utc());
|
||||
}
|
||||
|
||||
@ -34,14 +34,97 @@ class GeocodeEventLocations implements ShouldQueue
|
||||
public function handle(Geocoder $geocoder): void
|
||||
{
|
||||
// working counters
|
||||
$processed = 0;
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
$stats = [
|
||||
'locations' => [
|
||||
'processed' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
],
|
||||
'events' => [
|
||||
'processed' => 0,
|
||||
'created' => 0,
|
||||
'updated' => 0,
|
||||
'skipped' => 0,
|
||||
'failed' => 0,
|
||||
],
|
||||
];
|
||||
$handled = 0;
|
||||
|
||||
Log::info('GeocodeEventLocations: start', ['limit' => $this->limit]);
|
||||
|
||||
// first, geocode any location rows missing coordinates
|
||||
$locations = Location::query()
|
||||
->whereNotNull('raw_address')
|
||||
->where('raw_address', '<>', '')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('lat')->orWhereNull('lon');
|
||||
})
|
||||
->orderBy('id');
|
||||
|
||||
$stop = false;
|
||||
|
||||
$locations->chunkById(200, function ($chunk) use ($geocoder, &$stats, &$handled, &$stop) {
|
||||
foreach ($chunk as $loc) {
|
||||
if ($stop) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->limit !== null && $handled >= $this->limit) {
|
||||
$stop = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
$handled++;
|
||||
$stats['locations']['processed']++;
|
||||
|
||||
try {
|
||||
$norm = $geocoder->forward($loc->raw_address);
|
||||
|
||||
if (!$norm || !is_numeric($norm['lat'] ?? null) || !is_numeric($norm['lon'] ?? null)) {
|
||||
$stats['locations']['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed = false;
|
||||
if ($loc->lat === null && is_numeric($norm['lat'])) {
|
||||
$loc->lat = $norm['lat'];
|
||||
$changed = true;
|
||||
}
|
||||
if ($loc->lon === null && is_numeric($norm['lon'])) {
|
||||
$loc->lon = $norm['lon'];
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
foreach (['street', 'city', 'state', 'postal', 'country'] as $field) {
|
||||
if (empty($loc->{$field}) && !empty($norm[$field])) {
|
||||
$loc->{$field} = $norm[$field];
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$loc->save();
|
||||
$stats['locations']['updated']++;
|
||||
} else {
|
||||
$stats['locations']['skipped']++;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$stats['locations']['failed']++;
|
||||
Log::warning('GeocodeEventLocations: location failed', [
|
||||
'location_id' => $loc->id,
|
||||
'raw_address' => $loc->raw_address,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
|
||||
if ($stop) {
|
||||
Log::info('GeocodeEventLocations: done', $stats);
|
||||
return;
|
||||
}
|
||||
|
||||
// events that have a non-empty location string but no linked location row yet
|
||||
$todo = EventMeta::query()
|
||||
->whereNull('location_id')
|
||||
@ -49,46 +132,63 @@ class GeocodeEventLocations implements ShouldQueue
|
||||
->where('location', '<>', '')
|
||||
->orderBy('event_id'); // important for chunkById
|
||||
|
||||
$stop = false;
|
||||
|
||||
// log total to process (before limit)
|
||||
$total = (clone $todo)->count();
|
||||
Log::info('[geo] starting GeocodeEventLocations', ['total' => $total, 'limit' => $this->limit]);
|
||||
|
||||
// chunk through event_meta rows
|
||||
$todo->chunkById(200, function ($chunk) use ($geocoder, &$processed, &$created, &$updated, &$skipped, &$failed, &$stop) {
|
||||
$todo->chunkById(200, function ($chunk) use ($geocoder, &$stats, &$handled, &$stop) {
|
||||
foreach ($chunk as $meta) {
|
||||
if ($stop) {
|
||||
return false; // stop further chunking
|
||||
}
|
||||
|
||||
// respect limit if provided
|
||||
if ($this->limit !== null && $processed >= $this->limit) {
|
||||
if ($this->limit !== null && $handled >= $this->limit) {
|
||||
$stop = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// geocode the free-form location string; if it looks like a label,
|
||||
// fall back to the normalized location record's raw address
|
||||
// geocode the free-form location string; prefer an existing location match
|
||||
$query = $meta->location;
|
||||
$norm = $geocoder->forward($query);
|
||||
|
||||
if (!$norm) {
|
||||
$location = Location::where('display_name', $meta->location)
|
||||
->orWhere('raw_address', $meta->location)
|
||||
->first();
|
||||
|
||||
if ($location?->raw_address) {
|
||||
$query = $location->raw_address;
|
||||
$norm = $geocoder->forward($query);
|
||||
if (!$location) {
|
||||
// soft match on prefix when there is exactly one candidate
|
||||
$matches = Location::where('display_name', 'like', $meta->location . '%')
|
||||
->limit(2)
|
||||
->get();
|
||||
if ($matches->count() === 1) {
|
||||
$location = $matches->first();
|
||||
}
|
||||
}
|
||||
|
||||
if ($location) {
|
||||
// if we already have coords, just link and move on
|
||||
if (is_numeric($location->lat) && is_numeric($location->lon)) {
|
||||
$meta->location_id = $location->id;
|
||||
$meta->save();
|
||||
$handled++;
|
||||
$stats['events']['processed']++;
|
||||
$stats['events']['updated']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($location->raw_address) {
|
||||
$query = $location->raw_address;
|
||||
}
|
||||
}
|
||||
|
||||
$norm = $geocoder->forward($query);
|
||||
|
||||
// skip obvious non-address labels or unresolved queries
|
||||
if (!$norm || (!$norm['lat'] && !$norm['street'])) {
|
||||
$skipped++;
|
||||
$processed++;
|
||||
$stats['events']['skipped']++;
|
||||
$handled++;
|
||||
$stats['events']['processed']++;
|
||||
Log::info('GeocodeEventLocations: skipped', [
|
||||
'event_id' => $meta->event_id,
|
||||
'location' => $meta->location,
|
||||
@ -133,22 +233,24 @@ class GeocodeEventLocations implements ShouldQueue
|
||||
$existing->lon = $norm['lon'];
|
||||
$existing->raw_address ??= $norm['raw_address'];
|
||||
$existing->save();
|
||||
$updated++;
|
||||
$stats['events']['updated']++;
|
||||
}
|
||||
|
||||
if ($loc->wasRecentlyCreated) {
|
||||
$created++;
|
||||
$stats['events']['created']++;
|
||||
}
|
||||
|
||||
// link event_meta → locations
|
||||
$meta->location_id = $loc->id;
|
||||
$meta->save();
|
||||
|
||||
$processed++;
|
||||
$handled++;
|
||||
$stats['events']['processed']++;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$processed++;
|
||||
$stats['events']['failed']++;
|
||||
$handled++;
|
||||
$stats['events']['processed']++;
|
||||
Log::warning('GeocodeEventLocations: failed', [
|
||||
'event_id' => $meta->event_id,
|
||||
'location' => $meta->location,
|
||||
@ -158,6 +260,6 @@ class GeocodeEventLocations implements ShouldQueue
|
||||
}
|
||||
}, 'event_id');
|
||||
|
||||
Log::info('GeocodeEventLocations: done', compact('processed', 'created', 'updated', 'skipped', 'failed'));
|
||||
Log::info('GeocodeEventLocations: done', $stats);
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,14 +147,29 @@ class SyncSubscription implements ShouldQueue
|
||||
$end = isset($vevent->DTEND)
|
||||
? Carbon::instance($vevent->DTEND->getDateTime())->utc()
|
||||
: $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, [
|
||||
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
|
||||
'description' => (string) ($vevent->DESCRIPTION ?? ''),
|
||||
'location' => (string) ($vevent->LOCATION ?? ''),
|
||||
'all_day' => $dtStart->isFloating(),
|
||||
'all_day' => $isAllDay,
|
||||
'start_at' => $start,
|
||||
'end_at' => $end,
|
||||
'start_on' => $startOn,
|
||||
'end_on' => $endOn,
|
||||
'tzid' => $tzid,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,9 @@ class EventMeta extends Model
|
||||
'is_private',
|
||||
'start_at',
|
||||
'end_at',
|
||||
'start_on',
|
||||
'end_on',
|
||||
'tzid',
|
||||
'extra',
|
||||
];
|
||||
|
||||
@ -30,6 +33,8 @@ class EventMeta extends Model
|
||||
'is_private' => 'boolean',
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
'start_on' => 'date',
|
||||
'end_on' => 'date',
|
||||
'extra' => 'array',
|
||||
];
|
||||
|
||||
|
||||
@ -75,6 +75,7 @@ class Location extends Model
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
|
||||
if ($changed) {
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
@ -4,6 +4,10 @@ namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Database\Events\MigrationsEnded;
|
||||
use App\Jobs\GeocodeEventLocations;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@ -22,5 +26,26 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
// Calendar form
|
||||
Blade::component('calendars._form', 'calendar-form');
|
||||
|
||||
if (app()->runningInConsole()) {
|
||||
Event::listen(MigrationsEnded::class, function () {
|
||||
if (!config('services.geocoding.after_migrate')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config('services.geocoding.arcgis.api_key')) {
|
||||
Log::warning('Skipping geocode after migrate: missing ArcGIS API key');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
GeocodeEventLocations::runNow();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Geocode after migrate failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,6 +90,7 @@ class CalendarViewBuilder
|
||||
) {
|
||||
$startUtc = $occ['start'];
|
||||
$endUtc = $occ['end'];
|
||||
$isAllDay = (bool) ($e->meta?->all_day ?? false);
|
||||
|
||||
$startLocal = $startUtc->copy()->timezone($timezone);
|
||||
$endLocal = $endUtc->copy()->timezone($timezone);
|
||||
@ -97,7 +98,7 @@ class CalendarViewBuilder
|
||||
$startForGrid = $startUtc->copy()->tz($tz);
|
||||
$endForGrid = $endUtc->copy()->tz($tz);
|
||||
|
||||
if ($daytimeHours) {
|
||||
if ($daytimeHours && !$isAllDay) {
|
||||
$startMinutes = ($startForGrid->hour * 60) + $startForGrid->minute;
|
||||
$endMinutes = ($endForGrid->hour * 60) + $endForGrid->minute;
|
||||
|
||||
@ -136,6 +137,7 @@ class CalendarViewBuilder
|
||||
'calendar_slug' => $cal->slug,
|
||||
'title' => $e->meta->title ?? 'No title',
|
||||
'description' => $e->meta->description ?? 'No description.',
|
||||
'all_day' => $isAllDay,
|
||||
'start' => $startUtc->toIso8601String(),
|
||||
'end' => $endUtc->toIso8601String(),
|
||||
'start_ui' => $startLocal->format($uiFormat),
|
||||
@ -179,6 +181,9 @@ class CalendarViewBuilder
|
||||
$evTz = $ev['timezone'] ?? $tz;
|
||||
$start = Carbon::parse($ev['start'])->tz($evTz);
|
||||
$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();
|
||||
$d->lte($end->copy()->endOfDay());
|
||||
|
||||
@ -27,11 +27,17 @@ class EventRecurrence
|
||||
$startUtc = $data['start_utc'];
|
||||
$endUtc = $data['end_utc'];
|
||||
$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('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);
|
||||
$endLocal = $endUtc->copy()->tz($tzid);
|
||||
$vevent->add('DTSTART', $startLocal, ['TZID' => $tzid]);
|
||||
@ -62,7 +68,9 @@ class EventRecurrence
|
||||
if (!empty($exdates)) {
|
||||
foreach ($exdates as $ex) {
|
||||
$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]);
|
||||
} else {
|
||||
$vevent->add('EXDATE', $dt->utc());
|
||||
@ -74,7 +82,9 @@ class EventRecurrence
|
||||
if (!empty($rdates)) {
|
||||
foreach ($rdates as $r) {
|
||||
$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]);
|
||||
} else {
|
||||
$vevent->add('RDATE', $dt->utc());
|
||||
@ -117,19 +127,43 @@ class EventRecurrence
|
||||
?? new DateTimeZone('UTC');
|
||||
|
||||
$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 = [];
|
||||
while ($iter->valid()) {
|
||||
$start = Carbon::instance($iter->getDTStart());
|
||||
$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)) {
|
||||
break;
|
||||
if ($allDayTz) {
|
||||
$start = Carbon::parse($start->toDateString(), $allDayTz);
|
||||
$end = Carbon::parse($end->toDateString(), $allDayTz);
|
||||
}
|
||||
|
||||
$startUtc = $start->copy()->utc();
|
||||
$endUtc = $end->copy()->utc();
|
||||
|
||||
if ($startUtc->gt($rangeEnd)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if ($endUtc->lte($rangeStart)) {
|
||||
$iter->next();
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'start' => $startUtc,
|
||||
'end' => $endUtc,
|
||||
|
||||
@ -39,6 +39,7 @@ return [
|
||||
"provider" => env("GEOCODER", "arcgis"),
|
||||
"timeout" => (int) env("GEOCODER_TIMEOUT", 20),
|
||||
"user_agent" => env("GEOCODER_USER_AGENT", "Kithkin/LocalDev"),
|
||||
"after_migrate" => (bool) env("GEOCODE_AFTER_MIGRATE", false),
|
||||
"arcgis" => [
|
||||
"api_key" => env("ARCGIS_API_KEY"),
|
||||
"store" => (bool) env("ARCGIS_STORE_RESULTS", true),
|
||||
|
||||
@ -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.',
|
||||
'notes' => 'Notes',
|
||||
'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.',
|
||||
'notes' => 'Note',
|
||||
'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',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -78,7 +78,7 @@ button,
|
||||
> label,
|
||||
> button {
|
||||
@apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer;
|
||||
@apply border-md border-primary border-l-0 font-medium rounded-none whitespace-nowrap;
|
||||
@apply border-md border-primary border-l-0 text-base font-medium rounded-none whitespace-nowrap;
|
||||
transition: outline 125ms ease-in-out;
|
||||
box-shadow: var(--shadows);
|
||||
--shadows: none;
|
||||
@ -146,6 +146,10 @@ button,
|
||||
|
||||
/* small */
|
||||
&.button-group--sm {
|
||||
@apply h-9 max-h-9 text-sm;
|
||||
@apply h-9 max-h-9;
|
||||
|
||||
> label {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,21 +93,6 @@
|
||||
&[data-event-visible="9"] {
|
||||
.event:nth-child(n+10) { @apply hidden; }
|
||||
}
|
||||
&.is-expanded {
|
||||
position: relative;
|
||||
height: min-content;
|
||||
padding-bottom: 1px;
|
||||
z-index: 3;
|
||||
border: 1.5px solid black;
|
||||
border-radius: 0.5rem;
|
||||
scale: 1.05;
|
||||
width: 120%;
|
||||
margin-left: -10%;
|
||||
|
||||
div.more-events {
|
||||
@apply relative h-8;
|
||||
}
|
||||
}
|
||||
|
||||
/* events */
|
||||
.event {
|
||||
@ -135,6 +120,28 @@
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* expanded days with truncated events */
|
||||
&.is-expanded {
|
||||
position: relative;
|
||||
height: min-content;
|
||||
padding-bottom: 1px;
|
||||
z-index: 3;
|
||||
border: 1.5px solid black;
|
||||
border-radius: 0.5rem;
|
||||
scale: 1.05;
|
||||
width: 120%;
|
||||
margin-left: -10%;
|
||||
margin-bottom: -100%; /* needed to break out of row in webkit */
|
||||
|
||||
div.more-events {
|
||||
@apply relative h-8;
|
||||
}
|
||||
|
||||
.event {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -151,6 +158,12 @@
|
||||
--now-col-start: 1;
|
||||
--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 */
|
||||
hgroup {
|
||||
@apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-10;
|
||||
@ -170,7 +183,7 @@
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
&:not(:last-of-type)::after {
|
||||
@ -191,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 */
|
||||
ol.time {
|
||||
@apply grid z-0 pt-4;
|
||||
@ -344,12 +391,17 @@
|
||||
|
||||
hgroup {
|
||||
@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 {
|
||||
@apply gap-x-2;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-columns: repeat(var(--days), 1fr);
|
||||
--col: calc(100% / var(--days));
|
||||
|
||||
/* draw a 1px line at the start of each column repeat + highlight weekends */
|
||||
@ -392,6 +444,11 @@
|
||||
grid-template-columns: repeat(var(--days), 1fr);
|
||||
}
|
||||
|
||||
ol.day {
|
||||
@apply gap-x-2;
|
||||
grid-template-columns: repeat(var(--days), 1fr);
|
||||
}
|
||||
|
||||
ol.events {
|
||||
@apply gap-x-2;
|
||||
grid-template-columns: repeat(var(--days), 1fr);
|
||||
@ -474,6 +531,14 @@
|
||||
.calendar.time {
|
||||
grid-template-rows: 4rem auto 5rem;
|
||||
|
||||
&.allday {
|
||||
grid-template-rows: 4rem min-content auto 5rem;
|
||||
}
|
||||
|
||||
ol.day {
|
||||
@apply top-38;
|
||||
}
|
||||
|
||||
hgroup {
|
||||
div.day-header {
|
||||
@apply flex-row items-center justify-start gap-2;
|
||||
|
||||
@ -9,6 +9,16 @@ const SELECTORS = {
|
||||
colorPickerColor: '[data-colorpicker-color]',
|
||||
colorPickerHex: '[data-colorpicker-hex]',
|
||||
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',
|
||||
monthDayEvent: 'a.event',
|
||||
monthDayMore: '[data-day-more]',
|
||||
@ -16,6 +26,7 @@ const SELECTORS = {
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* htmx/global
|
||||
*/
|
||||
|
||||
@ -33,8 +44,10 @@ document.addEventListener('htmx:configRequest', (evt) => {
|
||||
})
|
||||
|
||||
/**
|
||||
*
|
||||
* global auth expiry redirect (fetch/axios)
|
||||
*/
|
||||
|
||||
const AUTH_REDIRECT_STATUSES = new Set([401, 419]);
|
||||
const redirectToLogin = () => {
|
||||
if (window.location.pathname !== '/login') {
|
||||
@ -67,9 +80,11 @@ if (window.axios) {
|
||||
}
|
||||
|
||||
/**
|
||||
* calendar ui
|
||||
* progressive enhancement on html form with no js
|
||||
*
|
||||
* calendar ui improvements
|
||||
*/
|
||||
|
||||
// progressive enhancement on html form with no JS
|
||||
document.addEventListener('change', (event) => {
|
||||
const target = event.target;
|
||||
|
||||
@ -90,9 +105,190 @@ document.addEventListener('change', (event) => {
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
// close event modal on back/forward navigation
|
||||
window.addEventListener('popstate', () => {
|
||||
if (!document.querySelector('article#calendar')) return;
|
||||
|
||||
const dialog = document.querySelector('dialog');
|
||||
if (!dialog?.open) return;
|
||||
|
||||
const modal = dialog.querySelector('#modal');
|
||||
if (!modal?.querySelector('[data-modal-kind="event"]')) return;
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const toggle = event.target.closest(SELECTORS.calendarExpandToggle);
|
||||
if (!toggle) return;
|
||||
@ -107,9 +303,11 @@ document.addEventListener('click', (event) => {
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* color picker component
|
||||
* native <input type="color"> + hex + random palette)
|
||||
*/
|
||||
|
||||
function initColorPickers(root = document) {
|
||||
const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test((v || '').trim());
|
||||
|
||||
@ -198,8 +396,10 @@ function initColorPickers(root = document) {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* month view overflow handling (progressive enhancement)
|
||||
*/
|
||||
|
||||
function initMonthOverflow(root = document) {
|
||||
const days = root.querySelectorAll(SELECTORS.monthDay);
|
||||
days.forEach((day) => updateMonthOverflow(day));
|
||||
@ -311,24 +511,7 @@ function updateMonthOverflow(dayEl) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* initialization
|
||||
*/
|
||||
|
||||
function initUI() {
|
||||
initColorPickers();
|
||||
initMonthOverflow();
|
||||
}
|
||||
|
||||
// initial bind
|
||||
document.addEventListener('DOMContentLoaded', initUI);
|
||||
|
||||
// rebind in htmx for swapped content
|
||||
document.addEventListener('htmx:afterSwap', (e) => {
|
||||
initColorPickers(e.target);
|
||||
initMonthOverflow(e.target);
|
||||
});
|
||||
|
||||
// show more events in a month calendar day when some are hidden
|
||||
document.addEventListener('click', (event) => {
|
||||
const button = event.target.closest(SELECTORS.monthDayMore);
|
||||
if (!button) return;
|
||||
@ -340,9 +523,35 @@ document.addEventListener('click', (event) => {
|
||||
updateMonthOverflow(dayEl);
|
||||
});
|
||||
|
||||
// month day resizer
|
||||
let monthResizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
if (!document.querySelector(SELECTORS.monthDay)) return;
|
||||
window.clearTimeout(monthResizeTimer);
|
||||
monthResizeTimer = window.setTimeout(() => initMonthOverflow(), 100);
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* initialization
|
||||
*/
|
||||
|
||||
function initUI() {
|
||||
initColorPickers();
|
||||
initEventAllDayToggles();
|
||||
initRecurrenceControls();
|
||||
initTimeViewAutoScroll();
|
||||
initMonthOverflow();
|
||||
}
|
||||
|
||||
// initial bind
|
||||
document.addEventListener('DOMContentLoaded', initUI);
|
||||
|
||||
// rebind in htmx for swapped content
|
||||
document.addEventListener('htmx:afterSwap', (e) => {
|
||||
initColorPickers(e.target);
|
||||
initEventAllDayToggles(e.target);
|
||||
initRecurrenceControls(e.target);
|
||||
initTimeViewAutoScroll(e.target);
|
||||
initMonthOverflow(e.target);
|
||||
});
|
||||
|
||||
@ -2,35 +2,48 @@
|
||||
<!-- 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
|
||||
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<x-input.label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
||||
<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>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<div class="input-row input-row--1">
|
||||
<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
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="current-password" />
|
||||
|
||||
required
|
||||
autocomplete="current-password" />
|
||||
<x-input.error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<div class="block mt-4">
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<label for="remember_me" class="inline-flex items-center gap-2">
|
||||
<input id="remember_me" type="checkbox" name="remember">
|
||||
<span>{{ __('Remember me') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 gap-4">
|
||||
@if (Route::has('password.request'))
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
@php
|
||||
$eventCalendar = $calendars->firstWhere('is_remote', false) ?? $calendars->first();
|
||||
$eventCalendarSlug = $eventCalendar['slug'] ?? null;
|
||||
@endphp
|
||||
|
||||
<x-app-layout id="calendar">
|
||||
|
||||
<x-slot name="aside">
|
||||
@ -95,11 +100,6 @@
|
||||
</button>
|
||||
</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') }}"
|
||||
@ -177,10 +177,22 @@
|
||||
</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
|
||||
</li>
|
||||
<li class="gap-0 flex flex-row">
|
||||
<a class="button button--icon" href="{{ route('calendar.settings') }}">
|
||||
<x-icon-settings />
|
||||
</a>
|
||||
@if($eventCalendarSlug)
|
||||
<a class="button button--icon"
|
||||
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>
|
||||
</menu>
|
||||
</x-slot>
|
||||
@ -192,7 +204,9 @@
|
||||
class="week time"
|
||||
:grid="$grid"
|
||||
:calendars="$calendars"
|
||||
:events="$events"
|
||||
:events="$events_time"
|
||||
:all_day_events="$events_all_day"
|
||||
:has_all_day="$has_all_day"
|
||||
:slots="$slots"
|
||||
:timeformat="$time_format"
|
||||
:hgroup="$hgroup"
|
||||
@ -209,13 +223,16 @@
|
||||
class="day time"
|
||||
:grid="$grid"
|
||||
:calendars="$calendars"
|
||||
:events="$events"
|
||||
:events="$events_time"
|
||||
:all_day_events="$events_all_day"
|
||||
:has_all_day="$has_all_day"
|
||||
:slots="$slots"
|
||||
:timeformat="$time_format"
|
||||
:hgroup="$hgroup"
|
||||
:active="$active"
|
||||
:density="$density"
|
||||
:daytime_hours="$daytime_hours"
|
||||
:timezone="$timezone"
|
||||
:now="$now"
|
||||
/>
|
||||
@break
|
||||
@ -224,13 +241,16 @@
|
||||
class="four time"
|
||||
:grid="$grid"
|
||||
:calendars="$calendars"
|
||||
:events="$events"
|
||||
:events="$events_time"
|
||||
:all_day_events="$events_all_day"
|
||||
:has_all_day="$has_all_day"
|
||||
:slots="$slots"
|
||||
:timeformat="$time_format"
|
||||
:hgroup="$hgroup"
|
||||
:active="$active"
|
||||
:density="$density"
|
||||
:daytime_hours="$daytime_hours"
|
||||
:timezone="$timezone"
|
||||
:now="$now"
|
||||
/>
|
||||
@break
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
'grid' => [],
|
||||
'calendars' => [],
|
||||
'events' => [],
|
||||
'all_day_events' => [],
|
||||
'has_all_day' => false,
|
||||
'class' => '',
|
||||
'slots' => [],
|
||||
'timeformat' => '',
|
||||
@ -10,11 +12,20 @@
|
||||
'density' => '30',
|
||||
'now' => [],
|
||||
'daytime_hours' => [],
|
||||
'timezone' => 'UTC',
|
||||
])
|
||||
|
||||
<section
|
||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
||||
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 }};"
|
||||
@class(['calendar', $class, 'allday' => $has_all_day ?? false])
|
||||
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>
|
||||
@foreach ($hgroup as $h)
|
||||
@ -24,10 +35,17 @@
|
||||
</div>
|
||||
@endforeach
|
||||
</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') }}">
|
||||
@foreach ($slots as $slot)
|
||||
<li>
|
||||
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
|
||||
<time datetime="{{ $slot['iso'] }}" data-slot-minutes="{{ $slot['minutes'] }}">{{ $slot['label'] }}</time>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
@ -40,7 +58,12 @@
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<div class="left">
|
||||
<a href="{{ route('account.locale') }}" class="timezone">{{ $timezone }}</a>
|
||||
</div>
|
||||
<div class="right">
|
||||
<x-calendar.time.daytime-hours view="day" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
<x-calendar.time.density view="day" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
'grid' => [],
|
||||
'calendars' => [],
|
||||
'events' => [],
|
||||
'all_day_events' => [],
|
||||
'has_all_day' => false,
|
||||
'class' => '',
|
||||
'slots' => [],
|
||||
'timeformat' => '',
|
||||
@ -10,16 +12,20 @@
|
||||
'density' => '30',
|
||||
'now' => [],
|
||||
'daytime_hours' => [],
|
||||
'timezone' => 'UTC',
|
||||
])
|
||||
|
||||
<section
|
||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
|
||||
style=
|
||||
"--now-row: {{ (int) $now['row'] }};
|
||||
@class(['calendar', $class, 'allday' => $has_all_day ?? false])
|
||||
data-density="{{ $density['step'] }}"
|
||||
data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}"
|
||||
style="
|
||||
--now-row: {{ (int) $now['row'] }};
|
||||
--now-offset: {{ $now['offset'] ?? 0 }};
|
||||
--now-col-start: {{ (int) $now['col_start'] }};
|
||||
--now-col-end: {{ (int) $now['col_end'] }};
|
||||
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
|
||||
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
|
||||
"
|
||||
>
|
||||
<hgroup>
|
||||
@foreach ($hgroup as $h)
|
||||
@ -46,10 +52,17 @@
|
||||
</div>
|
||||
@endforeach
|
||||
</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') }}">
|
||||
@foreach ($slots as $slot)
|
||||
<li>
|
||||
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
|
||||
<time datetime="{{ $slot['iso'] }}" data-slot-minutes="{{ $slot['minutes'] }}">{{ $slot['label'] }}</time>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
@ -62,7 +75,12 @@
|
||||
@endif
|
||||
</ol>
|
||||
<footer>
|
||||
<div class="left">
|
||||
<a href="{{ route('account.locale') }}" class="timezone">{{ $timezone }}</a>
|
||||
</div>
|
||||
<div class="right">
|
||||
<x-calendar.time.daytime-hours view="four" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
<x-calendar.time.density view="four" :density="$density" :daytime_hours="$daytime_hours" />
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
href="{{ route('calendar.event.show', $showParams) }}"
|
||||
hx-get="{{ route('calendar.event.show', $showParams) }}"
|
||||
hx-target="#modal"
|
||||
hx-push-url="false"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
style="--event-color: {{ $color }}"
|
||||
data-calendar="{{ $event['calendar_slug'] }}"
|
||||
|
||||
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>
|
||||
@ -30,7 +30,7 @@
|
||||
href="{{ route('calendar.event.show', $showParams) }}"
|
||||
hx-get="{{ route('calendar.event.show', $showParams) }}"
|
||||
hx-target="#modal"
|
||||
hx-push-url="false"
|
||||
hx-push-url="true"
|
||||
hx-swap="innerHTML"
|
||||
data-calendar="{{ $event['calendar_slug'] }}"
|
||||
>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
'grid' => [],
|
||||
'calendars' => [],
|
||||
'events' => [],
|
||||
'all_day_events' => [],
|
||||
'has_all_day' => false,
|
||||
'class' => '',
|
||||
'slots' => [],
|
||||
'timeformat' => '',
|
||||
@ -15,13 +17,17 @@
|
||||
])
|
||||
|
||||
<section
|
||||
class="calendar {{ $class }}" data-density="{{ $density['step'] }}" data-weekstart="{{ $weekstart }}"
|
||||
style=
|
||||
"--now-row: {{ (int) $now['row'] }};
|
||||
@class(['calendar', $class, 'allday' => $has_all_day ?? false])
|
||||
data-density="{{ $density['step'] }}"
|
||||
data-weekstart="{{ $weekstart }}"
|
||||
data-daytime-hours-enabled="{{ (int) ($daytime_hours['enabled'] ?? 0) }}"
|
||||
style="
|
||||
--now-row: {{ (int) $now['row'] }};
|
||||
--now-offset: {{ $now['offset'] ?? 0 }};
|
||||
--now-col-start: {{ (int) $now['col_start'] }};
|
||||
--now-col-end: {{ (int) $now['col_end'] }};
|
||||
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};"
|
||||
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
|
||||
"
|
||||
>
|
||||
<hgroup>
|
||||
@foreach ($hgroup as $h)
|
||||
@ -48,10 +54,17 @@
|
||||
</div>
|
||||
@endforeach
|
||||
</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') }}">
|
||||
@foreach ($slots as $slot)
|
||||
<li>
|
||||
<time datetime="{{ $slot['iso'] }}">{{ $slot['label'] }}</time>
|
||||
<time datetime="{{ $slot['iso'] }}" data-slot-minutes="{{ $slot['minutes'] }}">{{ $slot['label'] }}</time>
|
||||
</li>
|
||||
@endforeach
|
||||
</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,11 +1,11 @@
|
||||
<dialog
|
||||
hx-on:click="if(event.target === this) this.close()"
|
||||
hx-on:close="document.getElementById('modal').innerHTML=''"
|
||||
hx-on:close="const modal = document.getElementById('modal'); const isEvent = modal?.querySelector('[data-modal-kind=\'event\']'); const prevUrl = modal?.dataset?.prevUrl; modal.innerHTML=''; if (isEvent && prevUrl) history.replaceState({}, '', prevUrl);"
|
||||
>
|
||||
<div id="modal"
|
||||
hx-target="this"
|
||||
hx-on::before-swap="this.dataset.prevUrl = window.location.href"
|
||||
hx-on::after-swap="this.closest('dialog')?.showModal()"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
|
||||
@ -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="max-w-2xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST"
|
||||
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>
|
||||
@include('event.partials.form', compact('calendar', 'event', 'start', 'end', 'rrule'))
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
$map = $map ?? ['enabled' => false, 'needs_key' => false, 'url' => null];
|
||||
@endphp
|
||||
|
||||
<x-modal.content :class="$map['enabled'] ? 'with-map' : null">
|
||||
<x-modal.content data-modal-kind="event" :class="$map['enabled'] ? 'with-map' : null">
|
||||
<x-modal.title class="gap-4">
|
||||
<span class="inline-block h-4 w-4 rounded-full" style="background: {{ $event->color }};"></span>
|
||||
<h2>{{ $title }}</h2>
|
||||
|
||||
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