authorize('update', $calendar); // the instance for the signed-in user (provides the uri/slug) $instance = $calendar->instanceForUser(); $slug = $instance?->uri ?? $calendar->id; // fallback just in case // build a fresh event "shell" with meta defaults (keeps your view happy) $event = new Event; $event->meta = (object) [ 'title' => '', 'description' => '', 'location' => '', 'start_at' => null, 'end_at' => null, 'all_day' => false, 'category' => '', ]; // choose a timezone and derive defaults for start/end $tz = auth()->user()->timezone ?? config('app.timezone', 'UTC'); // if ?date=YYYY-MM-DD is present, start that day at 9am; otherwise "now" $anchor = $request->query('date') ? Carbon::parse($request->query('date'), $tz)->startOfDay()->addHours(9) : Carbon::now($tz); $anchor->second(0); $start_carbon = $anchor->copy(); $end_carbon = $anchor->copy()->addHour(); // format for $start = $start_carbon->format('Y-m-d\TH:i'); $end = $end_carbon->format('Y-m-d\TH:i'); return view('event.form', compact( 'calendar', // bound model (so route() can take the model directly) 'instance', // convenience in the view 'slug', // if you prefer passing just the slug into route() 'event', 'start', 'end' )); } /** * * edit event page */ public function edit(Calendar $calendar, Event $event) { $this->authorize('update', $calendar); $instance = $calendar->instanceForUser(); $timezone = $instance?->timezone ?? 'UTC'; $start = optional($event->meta?->start_at) ?->timezone($timezone) ?->format('Y-m-d\TH:i'); $end = optional($event->meta?->end_at) ?->timezone($timezone) ?->format('Y-m-d\TH:i'); return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end')); } /** * * single event view handling * * URL: /calendar/{uuid}/event/{event_id} */ public function show(Request $request, Calendar $calendar, Event $event) { // ensure the event really belongs to the parent calendar if ((int) $event->calendarid !== (int) $calendar->id) { abort(Response::HTTP_NOT_FOUND); } // authorize $this->authorize('view', $event); // eager-load metadata so the view has everything $event->load('meta'); $event->load('meta.venue'); // check for HTML; it sends `HX-Request: true` on every AJAX call $isHtmx = $request->header('HX-Request') === 'true'; // convert Sabre timestamps if meta is missing $start = $event->meta->start_at ?? Carbon::createFromTimestamp($event->firstoccurence); $end = $event->meta->end_at ?? ($event->lastoccurence ? Carbon::createFromTimestamp($event->lastoccurence) : $start); $data = compact('calendar', 'event', 'start', 'end'); return $isHtmx ? view('event.partials.details', $data) // tiny fragment for the modal : view('event.show', $data); // full-page fallback } /** * BACKEND METHODS * */ /** * * insert vevent into sabre’s calendarobjects + meta row */ public function store(Request $req, Calendar $calendar, Geocoder $geocoder) { $this->authorize('update', $calendar); $data = $req->validate([ 'title' => 'required|string|max:200', 'start_at' => 'required|date', 'end_at' => 'required|date|after:start_at', 'description' => 'nullable|string', 'location' => 'nullable|string', 'all_day' => 'sometimes|boolean', 'category' => 'nullable|string|max:50', // normalized fields from the suggestions ui (all optional) 'loc_display_name' => 'nullable|string', 'loc_place_name' => 'nullable|string', // optional if you add this hidden input 'loc_street' => 'nullable|string', 'loc_city' => 'nullable|string', 'loc_state' => 'nullable|string', 'loc_postal' => 'nullable|string', 'loc_country' => 'nullable|string', 'loc_lat' => 'nullable', 'loc_lon' => 'nullable', ]); $uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST); // parse local -> utc $clientTz = $calendar->timezone ?? 'UTC'; $start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $clientTz)->utc(); $end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $clientTz)->utc(); // normalize description/location for ics $description = str_replace("\n", '\\n', $data['description'] ?? ''); $locationStr = str_replace("\n", '\\n', $data['location'] ?? ''); // write dtstart/dtend as utc with "Z" $ical = <<format('Ymd\THis\Z')} DTSTART:{$start->format('Ymd\THis\Z')} DTEND:{$end->format('Ymd\THis\Z')} SUMMARY:{$data['title']} DESCRIPTION:$description LOCATION:$locationStr END:VEVENT END:VCALENDAR ICS; // create sabre object $event = Event::create([ 'calendarid' => $calendar->id, 'uri' => Str::uuid() . '.ics', 'lastmodified' => time(), 'etag' => md5($ical), 'size' => strlen($ical), 'componenttype' => 'VEVENT', 'uid' => $uid, 'calendardata' => $ical, ]); // resolve a location_id $locationId = null; $raw = $data['location'] ?? null; // did the user pick a suggestion (hidden normalized fields present)? $hasNormHints = $req->filled('loc_display_name') || $req->filled('loc_place_name') || $req->filled('loc_street') || $req->filled('loc_city') || $req->filled('loc_state') || $req->filled('loc_postal') || $req->filled('loc_country') || $req->filled('loc_lat') || $req->filled('loc_lon'); if ($raw) { if ($hasNormHints) { $norm = [ 'display_name' => $req->input('loc_display_name') ?: $raw, 'place_name' => $req->input('loc_place_name'), // fine if null 'raw_address' => $raw, 'street' => $req->input('loc_street'), 'city' => $req->input('loc_city'), 'state' => $req->input('loc_state'), 'postal' => $req->input('loc_postal'), 'country' => $req->input('loc_country'), 'lat' => $req->filled('loc_lat') ? (float) $req->input('loc_lat') : null, 'lon' => $req->filled('loc_lon') ? (float) $req->input('loc_lon') : null, ]; $loc = Location::findOrCreateNormalized($norm, $raw); $locationId = $loc->id; } else { // no hints: try geocoding the free-form string $norm = $geocoder->forward($raw); if ($norm) { $loc = Location::findOrCreateNormalized($norm, $raw); $locationId = $loc->id; } else { // label-only fallback so the event still links to a location row $loc = Location::labelOnly($raw); $locationId = $loc->id; } } } // meta row (store raw string and link to normalized location if we have one) $event->meta()->create([ 'title' => $data['title'], 'description' => $data['description'] ?? null, 'location' => $raw, 'location_id' => $locationId, 'all_day' => (bool) ($data['all_day'] ?? false), 'category' => $data['category'] ?? null, 'start_at' => $start, 'end_at' => $end, ]); return redirect()->route('calendar.show', $calendar); } /** * * update vevent + meta */ public function update(Request $req, Calendar $calendar, Event $event) { $this->authorize('update', $calendar); $data = $req->validate([ 'title' => 'required|string|max:200', 'start_at' => 'required|date', 'end_at' => 'required|date|after:start_at', 'description' => 'nullable|string', 'location' => 'nullable|string', 'category' => 'nullable|string|max:50', ]); // rebuild the icalendar payload $calendar_timezone = $calendar->timezone ?? 'UTC'; $start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $calendar_timezone)->setTimezone($calendar_timezone); $end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $calendar_timezone)->setTimezone($calendar_timezone); // prepare strings $description = $data['description'] ?? ''; $location = $data['location'] ?? ''; $description = str_replace("\n", '\\n', $description); $location = str_replace("\n", '\\n', $location); // note: keep the UID stable (CalDAV relies on it!) $uid = $event->uid; $ical = <<utc()->format('Ymd\\THis\\Z')} DTSTART;TZID={$calendar_timezone}:{$start->format('Ymd\\THis')} DTEND;TZID={$calendar_timezone}:{$end->format('Ymd\\THis')} SUMMARY:{$data['title']} DESCRIPTION:$description LOCATION:$location END:VEVENT END:VCALENDAR ICS; // persist changes $event->update([ 'calendardata' => $ical, 'etag' => md5($ical), 'lastmodified' => time(), ]); $event->meta()->updateOrCreate([], [ 'title' => $data['title'], 'description' => $data['description'] ?? null, 'location' => $data['location'] ?? null, 'all_day' => $data['all_day'] ?? false, 'category' => $data['category'] ?? null, 'start_at' => $start, 'end_at' => $end, ]); return redirect()->route('calendar.show', $calendar); } }