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);
}
}