WIP: February 2026 event improvements and calendar refactor #1

Draft
andrew wants to merge 28 commits from feb-2026-event-improvements into master
100 changed files with 8508 additions and 1790 deletions

View File

@ -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

View File

@ -0,0 +1,163 @@
<?php
namespace App\Http\Controllers;
use App\Models\Card;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Sabre\VObject\Reader;
class AttendeeController extends Controller
{
public function suggest(Request $request)
{
$q = trim((string) $request->input('q', $request->input('attendee', '')));
if ($q === '' || mb_strlen($q) < 2) {
return response()->view('event.partials.attendee-suggestions', ['suggestions' => []]);
}
$principal = $request->user()?->principal_uri;
if (!$principal) {
return response()->view('event.partials.attendee-suggestions', ['suggestions' => []]);
}
$query = Str::lower($q);
$contacts = [];
$seen = [];
$cards = Card::query()
->select('cards.carddata')
->join('addressbooks', 'addressbooks.id', '=', 'cards.addressbookid')
->where('addressbooks.principaluri', $principal)
->whereRaw('LOWER(cards.carddata) LIKE ?', ['%' . $query . '%'])
->orderByDesc('cards.lastmodified')
->limit(200)
->get();
foreach ($cards as $card) {
$parsed = $this->parseCardEmails((string) $card->carddata);
$name = $parsed['name'];
foreach ($parsed['emails'] as $email) {
$emailLc = Str::lower($email);
$matches = str_contains($emailLc, $query)
|| ($name !== '' && str_contains(Str::lower($name), $query));
if (!$matches || isset($seen[$emailLc])) {
continue;
}
$seen[$emailLc] = true;
$contacts[] = [
'email' => $emailLc,
'name' => $name !== '' ? $name : null,
'attendee_uri' => 'mailto:' . $emailLc,
'source' => 'contact',
];
}
}
if (filter_var($q, FILTER_VALIDATE_EMAIL)) {
$emailLc = Str::lower($q);
if (!isset($seen[$emailLc])) {
$contacts[] = [
'email' => $emailLc,
'name' => null,
'attendee_uri' => 'mailto:' . $emailLc,
'source' => 'email',
];
}
}
if (empty($contacts)) {
return response()->view('event.partials.attendee-suggestions', ['suggestions' => []]);
}
usort($contacts, function (array $a, array $b) use ($query) {
$scoreA = $this->score($a, $query);
$scoreB = $this->score($b, $query);
if ($scoreA !== $scoreB) {
return $scoreA <=> $scoreB;
}
return strcmp($a['email'], $b['email']);
});
$contacts = array_slice($contacts, 0, 8);
$emails = array_values(array_unique(array_map(fn ($item) => $item['email'], $contacts)));
$knownUsers = User::query()
->whereIn('email', $emails)
->pluck('email')
->map(fn ($email) => Str::lower((string) $email))
->flip()
->all();
foreach ($contacts as &$item) {
$item['verified'] = isset($knownUsers[$item['email']]);
}
unset($item);
return response()->view('event.partials.attendee-suggestions', [
'suggestions' => $contacts,
]);
}
private function parseCardEmails(string $carddata): array
{
$name = '';
$emails = [];
try {
$vcard = Reader::read($carddata);
$name = trim((string) ($vcard->FN ?? ''));
foreach ($vcard->select('EMAIL') as $emailProp) {
$email = Str::lower(trim((string) $emailProp->getValue()));
if ($email !== '') {
$emails[] = $email;
}
}
} catch (\Throwable $e) {
if (preg_match('/^FN:(.+)$/mi', $carddata, $fn)) {
$name = trim($fn[1]);
}
if (preg_match_all('/^EMAIL[^:]*:(.+)$/mi', $carddata, $matches)) {
foreach (($matches[1] ?? []) as $emailRaw) {
$email = Str::lower(trim((string) $emailRaw));
if ($email !== '') {
$emails[] = $email;
}
}
}
}
return [
'name' => $name,
'emails' => array_values(array_unique($emails)),
];
}
private function score(array $item, string $query): int
{
$email = Str::lower((string) ($item['email'] ?? ''));
$name = Str::lower((string) ($item['name'] ?? ''));
$source = (string) ($item['source'] ?? '');
if ($email === $query) {
return 0;
}
if ($email !== '' && str_starts_with($email, $query)) {
return 1;
}
if ($name !== '' && str_starts_with($name, $query)) {
return 2;
}
if ($source === 'contact') {
return 3;
}
return 4;
}
}

View File

@ -20,9 +20,9 @@ class PasswordController extends Controller
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
$request->user()->forceFill([
'password' => Hash::make($validated['password']),
]);
])->save();
return back()->with('status', 'password-updated');
}

View File

@ -35,11 +35,11 @@ class RegisteredUserController extends Controller
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$user = new User();
$user->name = $request->name;
$user->email = $request->email;
$user->password = $request->password;
$user->save();
event(new Registered($user));

File diff suppressed because it is too large Load Diff

View File

@ -161,12 +161,20 @@ class CalendarSettingsController extends Controller
// if it's remote
$icsUrl = null;
if (($meta?->is_remote ?? false) && $meta?->subscription_id) {
$shareUrl = null;
$isRemote = (bool) ($meta?->is_remote ?? false);
$isShared = (bool) ($meta?->is_shared ?? false);
if ($isRemote && $meta?->subscription_id) {
$icsUrl = Subscription::query()
->whereKey($meta->subscription_id)
->value('source');
}
if (!$isRemote && $isShared) {
$shareUrl = route('calendar.ics', ['calendarUri' => $instance->uri]);
}
return $this->frame(
'calendar.settings.calendar',
[
@ -175,6 +183,7 @@ class CalendarSettingsController extends Controller
'instance' => $instance,
'meta' => $meta,
'icsUrl' => $icsUrl,
'shareUrl' => $shareUrl,
'userTz' => $user->timezone,
]);
}
@ -195,6 +204,7 @@ class CalendarSettingsController extends Controller
'description' => ['nullable', 'string', 'max:500'],
'timezone' => ['nullable', 'string', 'max:64'],
'color' => ['nullable', 'regex:/^#[0-9A-F]{6}$/i'],
'is_shared' => ['nullable', 'boolean'],
]);
$timezone = filled($data['timezone'] ?? null)
@ -204,6 +214,8 @@ class CalendarSettingsController extends Controller
$color = $data['color'] ?? $instance->resolvedColor();
DB::transaction(function () use ($instance, $data, $timezone, $color) {
$isRemote = (bool) ($instance->meta?->is_remote ?? false);
$isShared = $isRemote ? false : (bool) ($data['is_shared'] ?? false);
// update sabre calendar instance (dav-facing)
$instance->update([
@ -220,6 +232,7 @@ class CalendarSettingsController extends Controller
'title' => $data['displayname'],
'color' => $color,
'color_fg' => contrast_text_color($color),
'is_shared' => $isShared,
]
);
});

View File

@ -3,21 +3,22 @@
namespace App\Http\Controllers;
use App\Models\Calendar;
use App\Models\CalendarInstance;
use App\Models\Event;
use App\Models\Location;
use App\Services\Event\EventAttendeeSynchronizer;
use App\Services\Event\EventRecurrence;
use App\Services\Location\Geocoder;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Sabre\VObject\Reader;
class EventController extends Controller
{
/**
* create a new event page
* create a new event
*/
public function create(Calendar $calendar, Request $request)
{
@ -47,21 +48,32 @@ class EventController extends Controller
$start = $anchor->copy()->format('Y-m-d\TH:i');
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
$rrule = '';
return view('event.form', compact(
$data = compact(
'calendar',
'instance',
'event',
'start',
'end',
'tz',
));
'rrule',
);
$data = array_merge($data, $this->buildRecurrenceFormData($request, $start, $tz, $rrule));
$data = array_merge($data, $this->buildAttendeeFormData($request));
$data = array_merge($data, $this->buildCalendarPickerData($request, $calendar, null));
if ($request->header('HX-Request') === 'true') {
return view('event.partials.form-modal', $data);
}
return view('event.form', $data);
}
/**
* edit event page
* edit event
*/
public function edit(Calendar $calendar, Event $event, Request $request)
public function edit(Calendar $calendar, Event $event, Request $request, EventRecurrence $recurrence)
{
$this->authorize('update', $calendar);
@ -73,7 +85,7 @@ class EventController extends Controller
$instance = $calendar->instanceForUser($request->user());
$tz = $this->displayTimezone($calendar, $request);
$event->load('meta');
$event->load('meta', 'attendees');
$start = $event->meta?->start_at
? Carbon::parse($event->meta->start_at)->timezone($tz)->format('Y-m-d\TH:i')
@ -83,13 +95,26 @@ class EventController extends Controller
? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i')
: null;
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz'));
$rrule = $event->meta?->extra['rrule']
?? $recurrence->extractRrule($event)
?? '';
$data = compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule');
$data = array_merge($data, $this->buildRecurrenceFormData($request, $start ?? '', $tz, $rrule));
$data = array_merge($data, $this->buildAttendeeFormData($request, $event));
$data = array_merge($data, $this->buildCalendarPickerData($request, $calendar, $event));
if ($request->header('HX-Request') === 'true') {
return view('event.partials.form-modal', $data);
}
return view('event.form', $data);
}
/**
* single event view handling
*/
public function show(Request $request, Calendar $calendar, Event $event)
public function show(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence)
{
if ((int) $event->calendarid !== (int) $calendar->id) {
abort(Response::HTTP_NOT_FOUND);
@ -97,27 +122,62 @@ class EventController extends Controller
$this->authorize('view', $event);
$event->load(['meta', 'meta.venue']);
$event->loadMissing(['meta', 'meta.venue', 'attendees']);
$isHtmx = $request->header('HX-Request') === 'true';
$tz = $this->displayTimezone($calendar, $request);
// prefer meta utc timestamps, fall back to sabre columns
$startUtc = $event->meta?->start_at
? Carbon::parse($event->meta->start_at)->utc()
: Carbon::createFromTimestamp($event->firstoccurence, 'UTC');
$instance = $calendar->instanceForUser($request->user());
if ($instance) {
$instance->loadMissing('meta');
}
$endUtc = $event->meta?->end_at
$calendarColor = calendar_color(
['color' => $instance?->meta?->color],
$instance?->calendarcolor
);
$calendarColorFg = $instance?->meta?->color_fg
?? contrast_text_color($calendarColor);
$calendarName = $instance?->displayname ?? __('common.calendar');
// prefer occurrence when supplied (recurring events), fall back to meta, then sabre columns
$occurrenceParam = $request->query('occurrence');
$occurrenceStart = null;
if ($occurrenceParam) {
try {
$occurrenceStart = Carbon::parse($occurrenceParam)->utc();
} catch (\Throwable $e) {
$occurrenceStart = null;
}
}
$occurrence = $occurrenceStart
? $recurrence->resolveOccurrence($event, $occurrenceStart)
: null;
$startUtc = $occurrence['start'] ?? ($event->meta?->start_at
? Carbon::parse($event->meta->start_at)->utc()
: Carbon::createFromTimestamp($event->firstoccurence, 'UTC'));
$endUtc = $occurrence['end'] ?? ($event->meta?->end_at
? Carbon::parse($event->meta->end_at)->utc()
: ($event->lastoccurence
? Carbon::createFromTimestamp($event->lastoccurence, 'UTC')
: $startUtc->copy());
: $startUtc->copy()));
// convert for display
$start = $startUtc->copy()->timezone($tz);
$end = $endUtc->copy()->timezone($tz);
$data = compact('calendar', 'event', 'start', 'end', 'tz');
if ($event->meta?->all_day && $end->gt($start)) {
$end = $end->copy()->subDay();
}
$map = $this->buildBasemapTiles($event->meta?->venue);
$event->setAttribute('color', $calendarColor);
$event->setAttribute('color_fg', $calendarColorFg);
$data = compact('calendar', 'event', 'start', 'end', 'tz', 'map', 'calendarName');
return $isHtmx
? view('event.partials.details', $data)
@ -127,18 +187,55 @@ class EventController extends Controller
/**
* insert vevent into sabres calendarobjects + meta row
*/
public function store(Request $request, Calendar $calendar, Geocoder $geocoder): RedirectResponse
public function store(
Request $request,
Calendar $calendar,
Geocoder $geocoder,
EventRecurrence $recurrence,
EventAttendeeSynchronizer $attendeeSync
): RedirectResponse
{
$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'],
'calendar_uri' => ['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'],
'attendees' => ['nullable', 'array'],
'attendees.*.attendee_uri' => ['nullable', 'string', 'max:500'],
'attendees.*.email' => ['nullable', 'email', 'max:255'],
'attendees.*.name' => ['nullable', 'string', 'max:200'],
'attendees.*.optional' => ['nullable', 'boolean'],
'attendees.*.role' => ['nullable', 'string', 'max:32'],
'attendees.*.partstat' => ['nullable', 'string', 'max:32'],
'attendees.*.cutype' => ['nullable', 'string', 'max:32'],
'attendees.*.rsvp' => ['nullable', 'boolean'],
'attendees.*.is_organizer' => ['nullable', 'boolean'],
'attendees.*.extra' => ['nullable', 'array'],
// normalized location hints (optional)
'loc_display_name' => ['nullable', 'string'],
@ -150,38 +247,60 @@ 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);
$targetCalendar = $this->resolveTargetCalendar($request, $calendar);
$this->authorize('update', $targetCalendar);
$tz = $this->displayTimezone($calendar, $request);
$isAllDay = (bool) ($data['all_day'] ?? false);
// parse input in display tz, store in utc
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
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);
$attendees = $this->resolveAttendeesPayload($request, null, $attendeeSync);
$description = $this->escapeIcsText($data['description'] ?? '');
$locationStr = $this->escapeIcsText($data['location'] ?? '');
$rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay));
$extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
// write dtstart/dtend as utc with "Z" so we have one canonical representation
$ical = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Kithkin//Laravel CalDAV//EN
BEGIN:VEVENT
UID:$uid
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')}
DTSTART:{$startUtc->format('Ymd\\THis\\Z')}
DTEND:{$endUtc->format('Ymd\\THis\\Z')}
SUMMARY:{$this->escapeIcsText($data['title'])}
DESCRIPTION:$description
LOCATION:$locationStr
END:VEVENT
END:VCALENDAR
ICS;
$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'] ?? '',
'tzid' => $rrule ? $tz : null,
'rrule' => $rrule,
'attendees' => $attendees,
]);
$event = Event::create([
'calendarid' => $calendar->id,
'calendarid' => $targetCalendar->id,
'uri' => Str::uuid() . '.ics',
'lastmodified' => time(),
'etag' => md5($ical),
@ -198,19 +317,31 @@ ICS;
'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,
]);
return redirect()->route('calendar.show', $calendar);
$attendeeSync->syncRows($event, $attendees);
return redirect()->route('calendar.show', $targetCalendar);
}
/**
* update vevent + meta
*/
public function update(Request $request, Calendar $calendar, Event $event): RedirectResponse
public function update(
Request $request,
Calendar $calendar,
Event $event,
EventRecurrence $recurrence,
EventAttendeeSynchronizer $attendeeSync
): RedirectResponse
{
$this->authorize('update', $calendar);
@ -218,44 +349,102 @@ ICS;
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'],
]);
'calendar_uri' => ['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'],
'attendees' => ['nullable', 'array'],
'attendees.*.attendee_uri' => ['nullable', 'string', 'max:500'],
'attendees.*.email' => ['nullable', 'email', 'max:255'],
'attendees.*.name' => ['nullable', 'string', 'max:200'],
'attendees.*.optional' => ['nullable', 'boolean'],
'attendees.*.role' => ['nullable', 'string', 'max:32'],
'attendees.*.partstat' => ['nullable', 'string', 'max:32'],
'attendees.*.cutype' => ['nullable', 'string', 'max:32'],
'attendees.*.rsvp' => ['nullable', 'boolean'],
'attendees.*.is_organizer' => ['nullable', 'boolean'],
'attendees.*.extra' => ['nullable', 'array'],
];
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);
$targetCalendar = $this->resolveTargetCalendar($request, $calendar);
$this->authorize('update', $targetCalendar);
$tz = $this->displayTimezone($calendar, $request);
$isAllDay = (bool) ($data['all_day'] ?? false);
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
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;
$attendees = $this->resolveAttendeesPayload($request, $event, $attendeeSync);
$description = $this->escapeIcsText($data['description'] ?? '');
$locationStr = $this->escapeIcsText($data['location'] ?? '');
$summary = $this->escapeIcsText($data['title']);
$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));
$ical = <<<ICS
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Kithkin//Laravel CalDAV//EN
BEGIN:VEVENT
UID:$uid
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')}
DTSTART:{$startUtc->format('Ymd\\THis\\Z')}
DTEND:{$endUtc->format('Ymd\\THis\\Z')}
SUMMARY:$summary
DESCRIPTION:$description
LOCATION:$locationStr
END:VEVENT
END:VCALENDAR
ICS;
$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'] ?? '',
'tzid' => $rruleForIcs ? $tz : null,
'rrule' => $rruleForIcs,
'exdate' => $extra['exdate'] ?? [],
'rdate' => $extra['rdate'] ?? [],
'attendees' => $attendees,
]);
$event->update([
'calendarid' => $targetCalendar->id,
'calendardata' => $ical,
'etag' => md5($ical),
'lastmodified' => time(),
@ -265,13 +454,19 @@ ICS;
'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,
]);
return redirect()->route('calendar.show', $calendar);
$attendeeSync->syncRows($event, $attendees);
return redirect()->route('calendar.show', $targetCalendar);
}
/**
@ -295,6 +490,40 @@ ICS;
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
*/
@ -305,6 +534,380 @@ ICS;
return $text;
}
private function normalizeRecurrenceInputs(Request $request): void
{
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;
}
$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 buildRecurrenceFormData(Request $request, string $startValue, string $tz, ?string $rrule): array
{
$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;
$startDate = null;
if ($startValue !== '') {
try {
$startDate = Carbon::parse($startValue, $tz);
} catch (\Throwable $e) {
$startDate = null;
}
}
$startDate = $startDate ?? Carbon::now($tz);
$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';
}
$setposMap = ['1' => 'first', '2' => 'second', '3' => 'third', '4' => 'fourth', '-1' => 'last'];
$repeatFrequency = $request->old('repeat_frequency', $freq ?: '');
$repeatInterval = $request->old('repeat_interval', $interval);
$repeatWeekdays = $request->old('repeat_weekdays', $byday ?: [$defaultWeekday]);
$repeatMonthDays = $request->old('repeat_month_days', $bymonthday ?: [$defaultMonthDay]);
$repeatMonthMode = $request->old('repeat_monthly_mode', $monthMode);
$repeatMonthWeek = $request->old('repeat_month_week', $setposMap[(string) $bysetpos] ?? $defaultMonthWeek);
$repeatMonthWeekday = $request->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'),
];
return compact(
'repeatFrequency',
'repeatInterval',
'repeatWeekdays',
'repeatMonthDays',
'repeatMonthMode',
'repeatMonthWeek',
'repeatMonthWeekday',
'rruleOptions',
'weekdayOptions',
'weekdayLong'
);
}
private function buildAttendeeFormData(Request $request, ?Event $event = null): array
{
$attendees = $request->old('attendees');
if (!is_array($attendees)) {
$attendees = $event?->attendees
? $event->attendees->map(function ($attendee) {
return [
'attendee_uri' => $attendee->attendee_uri,
'email' => $attendee->email,
'name' => $attendee->name,
'optional' => strtoupper((string) $attendee->role) === 'OPT-PARTICIPANT',
'rsvp' => $attendee->rsvp,
'verified' => !empty($attendee->attendee_user_id),
];
})->values()->all()
: [];
}
$attendees = array_values(array_map(function ($attendee) {
$row = is_array($attendee) ? $attendee : [];
$optional = array_key_exists('optional', $row)
? (bool) $row['optional']
: strtoupper((string) ($row['role'] ?? '')) === 'OPT-PARTICIPANT';
return [
'attendee_uri' => $row['attendee_uri'] ?? null,
'email' => $row['email'] ?? null,
'name' => $row['name'] ?? null,
'optional' => $optional,
'rsvp' => array_key_exists('rsvp', $row) ? (bool) $row['rsvp'] : true,
'verified' => (bool) ($row['verified'] ?? false),
];
}, $attendees));
return compact('attendees');
}
private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array
{
if ($rrule === null) {
return $extra; // no change requested
}
if ($rrule === '') {
unset($extra['rrule'], $extra['exdate'], $extra['rdate'], $extra['tzid']);
return $extra;
}
$extra['rrule'] = $rrule;
$extra['tzid'] = $tz;
$extra['exdate'] = $this->normalizeDateList($request->input('exdate', $extra['exdate'] ?? []), $tz);
$extra['rdate'] = $this->normalizeDateList($request->input('rdate', $extra['rdate'] ?? []), $tz);
return $extra;
}
private function normalizeDateList(mixed $value, string $tz): array
{
if (is_string($value)) {
$value = array_filter(array_map('trim', explode(',', $value)));
}
if (! is_array($value)) {
return [];
}
return array_values(array_filter(array_map(function ($item) use ($tz) {
if (! $item) {
return null;
}
return Carbon::parse($item, $tz)->utc()->toIso8601String();
}, $value)));
}
private function resolveAttendeesPayload(
Request $request,
?Event $event,
EventAttendeeSynchronizer $attendeeSync
): array {
if ($request->exists('attendees_present')) {
return $this->normalizeRequestAttendees((array) $request->input('attendees', []));
}
if (!$event) {
return [];
}
return $attendeeSync->extractFromCalendarData($event->calendardata);
}
private function normalizeRequestAttendees(array $attendees): array
{
$rows = [];
foreach ($attendees as $attendee) {
if (!is_array($attendee)) {
continue;
}
$isOptional = (bool) ($attendee['optional'] ?? false);
$rows[] = [
'attendee_uri' => $attendee['attendee_uri'] ?? null,
'email' => $attendee['email'] ?? null,
'name' => $attendee['name'] ?? null,
'role' => $isOptional ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT',
'partstat' => 'NEEDS-ACTION',
'cutype' => 'INDIVIDUAL',
'rsvp' => array_key_exists('rsvp', $attendee) ? (bool) $attendee['rsvp'] : true,
'is_organizer' => false,
'extra' => is_array($attendee['extra'] ?? null) ? $attendee['extra'] : null,
];
}
return $rows;
}
private function buildCalendarPickerData(Request $request, Calendar $calendar, ?Event $event = null): array
{
$instances = CalendarInstance::query()
->forUser($request->user())
->withUiMeta()
->ordered()
->get();
$selectedUri = old('calendar_uri');
if (!$selectedUri) {
if ($event) {
$selectedUri = $instances->firstWhere('calendarid', $event->calendarid)?->uri;
} else {
$selectedUri = $instances->first()?->uri;
}
}
$selected = $instances->firstWhere('uri', $selectedUri)
?? $instances->firstWhere('calendarid', $calendar->id)
?? $instances->first();
$calendarPickerOptions = $instances->map(function (CalendarInstance $instance) {
$color = $instance->resolvedColor($instance->calendarcolor);
return [
'uri' => $instance->uri,
'name' => $instance->displayname ?: __('common.calendar'),
'color' => $color,
];
})->values()->all();
$selectedCalendarUri = $selected?->uri;
$selectedCalendarName = $selected?->displayname ?: __('common.calendar');
$selectedCalendarColor = $selected?->resolvedColor($selected?->calendarcolor) ?? '#64748b';
return compact(
'calendarPickerOptions',
'selectedCalendarUri',
'selectedCalendarName',
'selectedCalendarColor',
);
}
private function resolveTargetCalendar(Request $request, Calendar $fallback): Calendar
{
$uri = trim((string) $request->input('calendar_uri', ''));
if ($uri === '') {
return $fallback;
}
$instance = CalendarInstance::query()
->forUser($request->user())
->where('uri', $uri)
->first();
if (!$instance) {
return $fallback;
}
return Calendar::query()->find($instance->calendarid) ?? $fallback;
}
/**
* resolve location_id from hints or geocoding
*/
@ -348,4 +951,53 @@ ICS;
return Location::labelOnly($raw)->id;
}
/**
* build static basemap tiles for an event location
*/
private function buildBasemapTiles(?Location $venue): array
{
$map = [
'enabled' => false,
'needs_key' => false,
'url' => null,
'zoom' => (int) config('services.geocoding.arcgis.basemap_zoom', 14),
];
if (!$venue || !is_numeric($venue->lat) || !is_numeric($venue->lon)) {
return $map;
}
$token = config('services.geocoding.arcgis.api_key');
if (!$token) {
$map['needs_key'] = true;
return $map;
}
$style = config('services.geocoding.arcgis.basemap_style', 'arcgis/light-gray');
$zoom = max(0, (int) $map['zoom']);
$lat = max(min((float) $venue->lat, 85.05112878), -85.05112878);
$lon = (float) $venue->lon;
$n = 2 ** $zoom;
$x = (int) floor(($lon + 180.0) / 360.0 * $n);
$latRad = deg2rad($lat);
$y = (int) floor((1.0 - log(tan($latRad) + (1 / cos($latRad))) / M_PI) / 2.0 * $n);
$base = 'https://static-map-tiles-api.arcgis.com/arcgis/rest/services/static-basemap-tiles-service/v1';
$tx = $x % $n;
if ($tx < 0) {
$tx += $n;
}
$ty = max(0, min($n - 1, $y));
$map['url'] = "{$base}/{$style}/static/tile/{$zoom}/{$ty}/{$tx}?token={$token}";
$map['enabled'] = true;
$map['zoom'] = $zoom;
return $map;
}
}

View File

@ -3,16 +3,27 @@
namespace App\Http\Controllers;
use App\Models\CalendarInstance;
use App\Models\EventMeta;
use Illuminate\Support\Facades\Response;
use Carbon\Carbon;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
class IcsController extends Controller
{
public function download(string $calendarUri)
{
$instance = CalendarInstance::where('uri', $calendarUri)->firstOrFail();
$instance = CalendarInstance::where('uri', $calendarUri)
->with('meta')
->firstOrFail();
$calendar = $instance->calendar()->with(['events.meta'])->firstOrFail();
$isRemote = (bool) ($instance->meta?->is_remote ?? false);
$isShared = (bool) ($instance->meta?->is_shared ?? false);
if ($isRemote || !$isShared) {
abort(404);
}
$calendar = $instance->calendar()->with(['events.meta.venue'])->firstOrFail();
$timezone = $instance->timezone ?? 'UTC';
$ical = $this->generateICalendarFeed($calendar->events, $timezone);
@ -25,39 +36,118 @@ class IcsController extends Controller
protected function generateICalendarFeed($events, string $tz): string
{
$output = [];
$output[] = 'BEGIN:VCALENDAR';
$output[] = 'VERSION:2.0';
$output[] = 'PRODID:-//Kithkin Calendar//EN';
$output[] = 'CALSCALE:GREGORIAN';
$output[] = 'METHOD:PUBLISH';
$vcalendar = new VCalendar();
$vcalendar->add('VERSION', '2.0');
$vcalendar->add('PRODID', '-//Kithkin Calendar//EN');
$vcalendar->add('CALSCALE', 'GREGORIAN');
$vcalendar->add('METHOD', 'PUBLISH');
foreach ($events as $event) {
$meta = $event->meta;
$ical = $event->calendardata ?? null;
if (!$meta || !$meta->start_at || !$meta->end_at) {
if ($ical) {
try {
$parsed = Reader::read($ical);
foreach ($parsed->select('VEVENT') as $vevent) {
$cloned = clone $vevent;
$this->applyLocationProperties($cloned, $event->meta);
$vcalendar->add($cloned);
}
continue;
} catch (\Throwable $e) {
// fall through to meta-based output
}
}
$meta = $event->meta;
if (!$meta) {
continue;
}
$start = Carbon::parse($meta->start_at)->timezone($tz)->format('Ymd\THis');
$end = Carbon::parse($meta->end_at)->timezone($tz)->format('Ymd\THis');
$vevent = $vcalendar->add('VEVENT', []);
$vevent->add('UID', $event->uid);
$vevent->add('SUMMARY', $meta->title ?? '(Untitled)');
$vevent->add('DESCRIPTION', $meta->description ?? '');
$output[] = 'BEGIN:VEVENT';
$output[] = 'UID:' . $event->uid;
$output[] = 'SUMMARY:' . $this->escape($meta->title ?? '(Untitled)');
$output[] = 'DESCRIPTION:' . $this->escape($meta->description ?? '');
$output[] = 'DTSTART;TZID=' . $tz . ':' . $start;
$output[] = 'DTEND;TZID=' . $tz . ':' . $end;
$output[] = 'DTSTAMP:' . Carbon::parse($event->lastmodified)->format('Ymd\THis\Z');
if ($meta->location) {
$output[] = 'LOCATION:' . $this->escape($meta->location);
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;
}
$output[] = 'END:VEVENT';
if ($event->lastmodified) {
$vevent->add('DTSTAMP', Carbon::createFromTimestamp($event->lastmodified)->utc());
}
$this->applyLocationProperties($vevent, $meta);
}
$output[] = 'END:VCALENDAR';
return $vcalendar->serialize();
}
return implode("\r\n", $output);
protected function applyLocationProperties($vevent, ?EventMeta $meta): void
{
if (!$meta) {
return;
}
$venue = $meta->venue;
$label = trim((string) ($meta->location_label ?? $meta->location ?? $venue?->display_name ?? ''));
$address = trim((string) ($venue?->raw_address ?? ''));
if ($address === '') {
$address = trim(implode("\n", array_filter([
$venue?->street,
trim(implode(' ', array_filter([$venue?->city, $venue?->state, $venue?->postal]))),
$venue?->country,
])));
}
$locationText = trim(implode("\n", array_filter([
$label !== '' ? $label : null,
$address !== '' ? $address : null,
])));
if (!isset($vevent->LOCATION) && $locationText !== '') {
$vevent->add('LOCATION', $locationText);
}
$lat = $venue?->lat;
$lon = $venue?->lon;
if (!is_numeric($lat) || !is_numeric($lon)) {
return;
}
if (!isset($vevent->GEO)) {
$vevent->add('GEO', sprintf('%.6f;%.6f', $lat, $lon));
}
if (!isset($vevent->{'X-APPLE-STRUCTURED-LOCATION'})) {
$params = [
'VALUE' => 'URI',
];
if ($address !== '') {
$params['X-ADDRESS'] = $address;
}
if ($label !== '') {
$params['X-TITLE'] = $label;
}
if (!empty($params['X-ADDRESS']) || !empty($params['X-TITLE'])) {
$params['X-APPLE-REFERENCEFRAME'] = '1';
$params['X-APPLE-RADIUS'] = '150';
$vevent->add(
'X-APPLE-STRUCTURED-LOCATION',
sprintf('geo:%.6f,%.6f', $lat, $lon),
$params
);
}
}
}
protected function escape(?string $text): string

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Services\Location\Geocoder;
use Illuminate\Support\Facades\Log;
class LocationController extends Controller
{

View File

@ -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,33 +132,68 @@ 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
$norm = $geocoder->forward($meta->location);
// geocode the free-form location string; prefer an existing location match
$query = $meta->location;
$location = Location::where('display_name', $meta->location)
->orWhere('raw_address', $meta->location)
->first();
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,
'query' => $query,
]);
continue;
}
@ -115,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,
@ -140,6 +260,6 @@ class GeocodeEventLocations implements ShouldQueue
}
}, 'event_id');
Log::info('GeocodeEventLocations: done', compact('processed', 'created', 'updated', 'skipped', 'failed'));
Log::info('GeocodeEventLocations: done', $stats);
}
}

View File

@ -7,6 +7,7 @@ use App\Models\CalendarInstance;
use App\Models\Event;
use App\Models\EventMeta;
use App\Models\Subscription;
use App\Services\Event\EventAttendeeSynchronizer;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -32,7 +33,7 @@ class SyncSubscription implements ShouldQueue
$this->subscription = $subscription;
}
public function handle(): void
public function handle(EventAttendeeSynchronizer $attendeeSync): void
{
// normalize the source a bit so comparisons/logging are consistent
$source = rtrim(trim((string) $this->subscription->source), '/');
@ -147,15 +148,32 @@ 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,
]);
$attendeeSync->syncFromCalendarData($object, $blob);
}
// sync is ok

View File

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Event extends Model
@ -48,17 +49,32 @@ class Event extends Model
return $this->hasOne(EventMeta::class, 'event_id');
}
public function attendees(): HasMany
{
return $this->hasMany(EventAttendee::class, 'event_id');
}
/**
* scopes and helpers
**/
public function scopeInRange($query, $start, $end)
{
return $query->whereHas('meta', function ($q) use ($start, $end) {
$q->where('start_at', '<=', $end)
->where(function ($qq) use ($start) {
$qq->where('end_at', '>=', $start)
->orWhereNull('end_at');
});
return $query->where(function ($q) use ($start, $end) {
$q->whereHas('meta', function ($meta) use ($start, $end) {
$meta->where(function ($range) use ($start, $end) {
$range->where('start_at', '<=', $end)
->where(function ($bounds) use ($start) {
$bounds->where('end_at', '>=', $start)
->orWhereNull('end_at');
});
})
->orWhereNotNull('extra->rrule');
})
->orWhere(function ($ical) {
$ical->where('calendardata', 'like', '%RRULE%')
->orWhere('calendardata', 'like', '%RDATE%')
->orWhere('calendardata', 'like', '%EXDATE%');
});
});
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventAttendee extends Model
{
protected $table = 'event_attendees';
protected $fillable = [
'event_id',
'attendee_user_id',
'attendee_uri',
'email',
'name',
'role',
'partstat',
'cutype',
'rsvp',
'is_organizer',
'extra',
];
protected $casts = [
'rsvp' => 'boolean',
'is_organizer' => 'boolean',
'extra' => 'array',
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class, 'event_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'attendee_user_id');
}
}

View File

@ -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',
];

View File

@ -75,6 +75,7 @@ class Location extends Model
$changed = true;
}
if ($changed) {
$existing->save();
}

View File

@ -30,6 +30,7 @@ class User extends Authenticatable
'firstname',
'lastname',
'displayname',
'name',
'email',
'timezone',
'phone',
@ -59,6 +60,62 @@ class User extends Authenticatable
];
}
/**
* Expose a Breeze-compatible "name" attribute without a physical column.
* Preference: displayname (explicit override), then first + last, then email.
*/
public function getNameAttribute(): string
{
$displayname = is_string($this->displayname) ? trim($this->displayname) : '';
if ($displayname !== '') {
return $displayname;
}
$first = is_string($this->firstname) ? trim($this->firstname) : '';
$last = is_string($this->lastname) ? trim($this->lastname) : '';
$full = trim($first . ' ' . $last);
if ($full !== '') {
return $full;
}
return (string) ($this->email ?? '');
}
/**
* Map "name" writes to first/last names, keeping displayname optional.
*/
public function setNameAttribute(?string $value): void
{
$incoming = trim((string) $value);
$currentFirst = is_string($this->attributes['firstname'] ?? null)
? trim((string) $this->attributes['firstname'])
: '';
$currentLast = is_string($this->attributes['lastname'] ?? null)
? trim((string) $this->attributes['lastname'])
: '';
$currentGenerated = trim($currentFirst . ' ' . $currentLast);
if ($incoming === '') {
$this->attributes['firstname'] = null;
$this->attributes['lastname'] = null;
return;
}
$parts = preg_split('/\s+/', $incoming, 2);
$this->attributes['firstname'] = $parts[0] ?? null;
$this->attributes['lastname'] = $parts[1] ?? null;
$displayname = is_string($this->attributes['displayname'] ?? null)
? trim((string) $this->attributes['displayname'])
: '';
if ($displayname !== '' && $displayname === $currentGenerated) {
$this->attributes['displayname'] = $incoming;
}
}
/**
* user can own many calendars
*/
@ -102,6 +159,11 @@ class User extends Authenticatable
return $this->hasMany(UserSetting::class, 'user_id');
}
public function eventAttendees(): HasMany
{
return $this->hasMany(EventAttendee::class, 'attendee_user_id');
}
/**
* get a user setting by key
*/

View File

@ -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(),
]);
}
});
}
}
}

View File

@ -0,0 +1,165 @@
<?php
namespace App\Services\Calendar;
use Carbon\Carbon;
use Illuminate\Http\Request;
class CalendarRangeResolver
{
public const VIEWS = ['day', 'week', 'month', 'four'];
/**
* Normalize view + date inputs into a resolved view and date range.
*
* @return array [$view, ['start' => Carbon, 'end' => Carbon]]
*/
public function resolveRange(
Request $request,
string $tz,
int $weekStart,
int $weekEnd,
string $defaultView,
string $defaultDate
): array {
$requestView = $request->query('view', $defaultView);
$view = in_array($requestView, self::VIEWS, true)
? $requestView
: 'month';
$date = $request->query('date', $defaultDate);
$anchor = $this->safeDate($date, $tz, $defaultDate);
switch ($view) {
case 'day':
$start = $anchor->copy()->startOfDay();
$end = $anchor->copy()->endOfDay();
break;
case 'week':
$start = $anchor->copy()->startOfWeek($weekStart);
$end = $anchor->copy()->endOfWeek($weekEnd);
break;
case 'four':
$start = $anchor->copy()->startOfDay();
$end = $anchor->copy()->addDays(3)->endOfDay();
break;
default: // month
$start = $anchor->copy()->startOfMonth();
$end = $anchor->copy()->endOfMonth();
}
return [$view, ['start' => $start, 'end' => $end]];
}
/**
* Calendar grid span differs from logical range (e.g. month padding).
*/
public function gridSpan(string $view, array $range, int $weekStart, int $weekEnd): array
{
switch ($view) {
case 'day':
$start = $range['start']->copy()->startOfDay();
$end = $range['start']->copy()->endOfDay();
break;
case 'week':
$start = $range['start']->copy()->startOfWeek($weekStart);
$end = $range['start']->copy()->endOfWeek($weekEnd);
break;
case 'four':
$start = $range['start']->copy()->startOfDay();
$end = $range['start']->copy()->addDays(3);
break;
default: // month
$start = $range['start']->copy()->startOfMonth()->startOfWeek($weekStart);
$end = $range['end']->copy()->endOfMonth()->endOfWeek($weekEnd);
}
return ['start' => $start, 'end' => $end];
}
/**
* Navigation dates for header controls.
*/
public function navDates(string $view, Carbon $start, string $tz): array
{
$start = $start->copy()->tz($tz);
return match ($view) {
'day' => [
'prev' => $start->copy()->subDay()->toDateString(),
'next' => $start->copy()->addDay()->toDateString(),
'today' => Carbon::today($tz)->toDateString(),
],
'week' => [
'prev' => $start->copy()->subWeek()->toDateString(),
'next' => $start->copy()->addWeek()->toDateString(),
'today' => Carbon::today($tz)->toDateString(),
],
'four' => [
'prev' => $start->copy()->subDays(4)->toDateString(),
'next' => $start->copy()->addDays(4)->toDateString(),
'today' => Carbon::today($tz)->toDateString(),
],
default => [
'prev' => $start->copy()->subMonth()->startOfMonth()->toDateString(),
'next' => $start->copy()->addMonth()->startOfMonth()->toDateString(),
'today' => Carbon::today($tz)->toDateString(),
],
};
}
/**
* Title text for the calendar header.
*/
public function headerTitle(string $view, Carbon $start, Carbon $end): array
{
$sameDay = $start->isSameDay($end);
$sameMonth = $start->isSameMonth($end);
$sameYear = $start->isSameYear($end);
$strong = $start->format('F');
$span = $start->format('Y');
if ($view === 'day' || $sameDay) {
return [
'strong' => $start->format('F j'),
'span' => $start->format('Y'),
];
}
if (in_array($view, ['week', 'four'], true)) {
if ($sameMonth && $sameYear) {
return [
'strong' => $start->format('F j') . ' to ' . $end->format('j'),
'span' => $start->format('Y'),
];
}
if ($sameYear) {
return [
'strong' => $start->format('F') . ' to ' . $end->format('F'),
'span' => $start->format('Y'),
];
}
return [
'strong' => $start->format('F Y') . ' to ' . $end->format('F Y'),
'span' => null,
];
}
return ['strong' => $strong, 'span' => $span];
}
/**
* Safe date parsing with fallback to a default date string.
*/
public function safeDate(string $date, string $tz, string $fallbackDate): Carbon
{
try {
return Carbon::createFromFormat('Y-m-d', $date, $tz)->startOfDay();
} catch (\Throwable $e) {
return Carbon::createFromFormat('Y-m-d', $fallbackDate, $tz)->startOfDay();
}
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services\Calendar;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
class CalendarSettingsPersister
{
public function defaults(User $user, string $tz): array
{
$defaultView = $user->getSetting('calendar.last_view', 'month');
$defaultDate = $user->getSetting('calendar.last_date', Carbon::today($tz)->toDateString());
$defaultDensity = (int) $user->getSetting('calendar.last_density', 30);
$defaultDaytimeHours = (int) $user->getSetting('calendar.daytime_hours', 0);
$weekStartPref = $user->getSetting('calendar.week_start', 'sunday');
$weekStartPref = in_array($weekStartPref, ['sunday', 'monday'], true)
? $weekStartPref
: 'sunday';
$weekStart = $weekStartPref === 'monday' ? Carbon::MONDAY : Carbon::SUNDAY;
$weekEnd = (int) (($weekStart + 6) % 7);
return [
'view' => $defaultView,
'date' => $defaultDate,
'density' => $defaultDensity,
'daytime_hours' => $defaultDaytimeHours,
'week_start_pref' => $weekStartPref,
'week_start' => $weekStart,
'week_end' => $weekEnd,
];
}
public function resolveDensity(Request $request, int $defaultDensity): array
{
$stepMinutes = (int) $request->query('density', $defaultDensity);
if (!in_array($stepMinutes, [15, 30, 60], true)) {
$stepMinutes = 30;
}
$labelEvery = match ($stepMinutes) {
15 => 1,
30 => 2,
60 => 4,
};
return [
'step' => $stepMinutes,
'label_every' => $labelEvery,
];
}
public function resolveDaytimeHours(Request $request, int $defaultDaytimeHours): bool
{
return (int) $request->query('daytime_hours', $defaultDaytimeHours) === 1;
}
public function daytimeHoursRange(): array
{
return ['start' => 8, 'end' => 18];
}
public function persist(
User $user,
Request $request,
string $view,
Carbon $rangeStart,
int $stepMinutes,
bool $daytimeHoursEnabled
): void {
if ($request->hasAny(['view', 'date', 'density'])) {
$user->setSetting('calendar.last_view', $view);
$user->setSetting('calendar.last_date', $rangeStart->toDateString());
$user->setSetting('calendar.last_density', (string) $stepMinutes);
}
if ($request->has('daytime_hours')) {
$user->setSetting('calendar.daytime_hours', $daytimeHoursEnabled ? '1' : '0');
}
}
}

View File

@ -0,0 +1,600 @@
<?php
namespace App\Services\Calendar;
use App\Services\Event\EventRecurrence;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class CalendarViewBuilder
{
/**
* Expand events (including recurrence) into view-ready payloads.
*/
public function buildEventPayloads(
Collection $events,
Collection $calendarMap,
string $timeFormat,
string $view,
array $range,
string $tz,
EventRecurrence $recurrence,
array $span,
?array $daytimeHours = null
): Collection {
$uiFormat = $timeFormat === '24' ? 'H:i' : 'g:ia';
$spanStartUtc = $span['start']->copy()->utc();
$spanEndUtc = $span['end']->copy()->utc();
$gridStartMinutes = $daytimeHours ? ((int) $daytimeHours['start'] * 60) : 0;
$gridEndMinutes = $daytimeHours ? ((int) $daytimeHours['end'] * 60) : (24 * 60);
$payloads = $events->flatMap(function ($e) use (
$calendarMap,
$uiFormat,
$view,
$range,
$tz,
$recurrence,
$spanStartUtc,
$spanEndUtc,
$gridStartMinutes,
$gridEndMinutes,
$daytimeHours
) {
$cal = $calendarMap[$e->calendarid];
$timezone = $cal->timezone ?? config('app.timezone');
$color = $cal['meta_color']
?? $cal['calendarcolor']
?? default_calendar_color();
$colorFg = $cal['meta_color_fg']
?? contrast_text_color($color);
$occurrences = [];
$isRecurring = $recurrence->isRecurring($e);
if ($isRecurring) {
$occurrences = $recurrence->expand($e, $spanStartUtc, $spanEndUtc);
}
if (empty($occurrences) && !$isRecurring) {
$meta = $e->meta;
$isAllDay = (bool) ($meta?->all_day ?? false);
$startUtc = null;
$endUtc = null;
if ($isAllDay && $meta?->start_on && $meta?->end_on) {
$allDayTz = $meta?->tzid
?? ($meta?->extra['tzid'] ?? null)
?? $timezone;
$startUtc = Carbon::parse($meta->start_on->toDateString(), $allDayTz)
->startOfDay()
->utc();
$endUtc = Carbon::parse($meta->end_on->toDateString(), $allDayTz)
->startOfDay()
->utc();
} else {
if ($meta?->start_at instanceof Carbon) {
$startUtc = $meta->start_at->copy()->utc();
} elseif ($meta?->start_at) {
$startUtc = Carbon::parse($meta->start_at, 'UTC');
} else {
$startUtc = Carbon::createFromTimestamp($e->firstoccurence, 'UTC');
}
if ($meta?->end_at instanceof Carbon) {
$endUtc = $meta->end_at->copy()->utc();
} elseif ($meta?->end_at) {
$endUtc = Carbon::parse($meta->end_at, 'UTC');
} else {
$endUtc = $e->lastoccurence
? Carbon::createFromTimestamp($e->lastoccurence, 'UTC')
: $startUtc->copy();
}
}
$occurrences[] = [
'start' => $startUtc,
'end' => $endUtc,
'recurrence_id' => null,
];
}
return collect($occurrences)->map(function ($occ) use (
$e,
$cal,
$uiFormat,
$view,
$range,
$tz,
$timezone,
$color,
$colorFg,
$gridStartMinutes,
$gridEndMinutes,
$daytimeHours,
$spanStartUtc,
$spanEndUtc
) {
$startUtc = $occ['start'];
$endUtc = $occ['end'];
$isAllDay = (bool) ($e->meta?->all_day ?? false);
if ($endUtc->lte($spanStartUtc) || $startUtc->gte($spanEndUtc)) {
return null;
}
$startLocal = $startUtc->copy()->timezone($timezone);
$endLocal = $endUtc->copy()->timezone($timezone);
$startForGrid = $startUtc->copy()->tz($tz);
$endForGrid = $endUtc->copy()->tz($tz);
if ($daytimeHours && !$isAllDay) {
$startMinutes = ($startForGrid->hour * 60) + $startForGrid->minute;
$endMinutes = ($endForGrid->hour * 60) + $endForGrid->minute;
if ($endMinutes <= $gridStartMinutes || $startMinutes >= $gridEndMinutes) {
return null;
}
$displayStartMinutes = max($startMinutes, $gridStartMinutes);
$displayEndMinutes = min($endMinutes, $gridEndMinutes);
$startForGrid = $startForGrid->copy()->startOfDay()->addMinutes($displayStartMinutes);
$endForGrid = $endForGrid->copy()->startOfDay()->addMinutes($displayEndMinutes);
}
$placement = $this->slotPlacement(
$startForGrid,
$endForGrid,
$range['start']->copy()->tz($tz),
$view,
15,
$gridStartMinutes,
$gridEndMinutes
);
$occurrenceId = $occ['recurrence_id']
? ($e->id . ':' . $occ['recurrence_id'])
: (string) $e->id;
return [
'id' => $e->id,
'occurrence_id' => $occurrenceId,
'occurrence' => $occ['recurrence_id']
? $startUtc->toIso8601String()
: null,
'calendar_id' => $e->calendarid,
'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),
'end_ui' => $endLocal->format($uiFormat),
'timezone' => $timezone,
'visible' => $cal->visible,
'color' => $color,
'color_fg' => $colorFg,
'start_row' => $placement['start_row'],
'end_row' => $placement['end_row'],
'row_span' => $placement['row_span'],
'start_col' => $placement['start_col'],
'duration' => $placement['duration'],
];
})->filter()->values();
})->filter();
// ensure chronological ordering across calendars for all views
$payloads = $payloads
->sortBy('start')
->keyBy('occurrence_id');
return $this->applyOverlapLayout($payloads, $view);
}
/**
* Assemble an array of day-objects for the requested view.
*/
public function buildCalendarGrid(
string $view,
array $range,
Collection $events,
string $tz,
array $span
): array {
['start' => $grid_start, 'end' => $grid_end] = $span;
$today = Carbon::today($tz)->toDateString();
$events_by_day = [];
foreach ($events as $ev) {
$evTz = $ev['timezone'] ?? $tz;
$start = Carbon::parse($ev['start'])->tz($evTz);
$end = $ev['end'] ? Carbon::parse($ev['end'])->tz($evTz) : $start;
if (!empty($ev['all_day']) && $end->gt($start)) {
$end = $end->copy()->subSecond();
}
for ($d = $start->copy()->startOfDay();
$d->lte($end->copy()->endOfDay());
$d->addDay()) {
$key = $d->toDateString();
$events_by_day[$key][] = $ev['occurrence_id'] ?? $ev['id'];
}
}
$days = [];
for ($day = $grid_start->copy(); $day->lte($grid_end); $day->addDay()) {
$iso = $day->toDateString();
$days[] = [
'date' => $iso,
'label' => $day->format('j'),
'in_month' => $day->month === $range['start']->month,
'is_today' => $day->isSameDay($today),
'events' => array_fill_keys($events_by_day[$iso] ?? [], true),
];
}
return $view === 'month'
? ['days' => $days, 'weeks' => array_chunk($days, 7)]
: ['days' => $days];
}
/**
* Build the mini-month grid for day buttons.
*/
public function buildMiniGrid(
Carbon $monthStart,
Collection $events,
string $tz,
int $weekStart,
int $weekEnd
): array {
$monthStart = $monthStart->copy()->tz($tz);
$monthEnd = $monthStart->copy()->endOfMonth();
$gridStart = $monthStart->copy()->startOfWeek($weekStart);
$gridEnd = $monthEnd->copy()->endOfWeek($weekEnd);
if ($gridStart->diffInDays($gridEnd) + 1 < 42) {
$gridEnd->addWeek();
}
$today = Carbon::today($tz)->toDateString();
$byDay = [];
foreach ($events as $ev) {
$s = Carbon::parse($ev['start'])->tz($tz);
$e = $ev['end'] ? Carbon::parse($ev['end'])->tz($tz) : $s;
for ($d = $s->copy()->startOfDay(); $d->lte($e->copy()->endOfDay()); $d->addDay()) {
$byDay[$d->toDateString()][] = $ev['occurrence_id'] ?? $ev['id'];
}
}
$days = [];
for ($d = $gridStart->copy(); $d->lte($gridEnd); $d->addDay()) {
$iso = $d->toDateString();
$days[] = [
'date' => $iso,
'label' => $d->format('j'),
'in_month' => $d->between($monthStart, $monthEnd),
'is_today' => $iso === $today,
'events' => $byDay[$iso] ?? [],
];
}
return ['days' => $days];
}
/**
* Create the time gutter for time-based views.
*/
public function timeSlots(
Carbon $dayStart,
string $tz,
string $timeFormat,
?array $daytimeHours = null
): array {
$minutesPerSlot = 15;
$startMinutes = 0;
$endMinutes = 24 * 60;
if (is_array($daytimeHours)) {
$startMinutes = (int) $daytimeHours['start'] * 60;
$endMinutes = (int) $daytimeHours['end'] * 60;
}
$slotsPerDay = intdiv(max(0, $endMinutes - $startMinutes), $minutesPerSlot);
$format = $timeFormat === '24' ? 'H:i' : 'g:i a';
$slots = [];
$t = $dayStart->copy()->tz($tz)->startOfDay()->addMinutes($startMinutes);
for ($i = 0; $i < $slotsPerDay; $i++) {
$slots[] = [
'iso' => $t->toIso8601String(),
'label' => $t->format($format),
'key' => $t->format('H:i'),
'index' => $i,
'minutes' => $startMinutes + ($i * $minutesPerSlot),
'duration' => $minutesPerSlot,
];
$t->addMinutes($minutesPerSlot);
}
return $slots;
}
/**
* Create the specific view's date headers.
*/
public function viewHeaders(string $view, array $range, string $tz, int $weekStart): array
{
$start = $range['start']->copy()->tz($tz);
$end = $range['end']->copy()->tz($tz);
$today = Carbon::today($tz)->toDateString();
if ($view === 'month') {
return collect($this->weekdayHeaders($tz, $weekStart))
->map(fn ($h) => $h + ['is_today' => false])
->all();
}
$headers = [];
for ($d = $start->copy()->startOfDay(); $d->lte($end); $d->addDay()) {
$date = $d->toDateString();
$headers[] = [
'date' => $d->toDateString(),
'day' => $d->format('j'),
'dow' => $d->translatedFormat('l'),
'dow_short' => $d->translatedFormat('D'),
'month' => $d->translatedFormat('M'),
'is_today' => $date === $today,
];
}
return $headers;
}
/**
* Specific headers for month views (full and mini).
*/
public function weekdayHeaders(string $tz, int $weekStart): array
{
$headers = [];
$d = Carbon::now($tz)->startOfWeek($weekStart);
for ($i = 0; $i < 7; $i++) {
$headers[] = [
'key' => $i,
'label' => $d->translatedFormat('D'),
];
$d->addDay();
}
return $headers;
}
/**
* Place the "now" indicator on the grid for time-based views.
*/
public function nowIndicator(
string $view,
array $range,
string $tz,
int $minutesPerSlot = 15,
int $gutterCols = 1,
?array $daytimeHours = null
): array {
if (!in_array($view, ['day', 'week', 'four'], true)) {
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
}
$now = Carbon::now($tz);
$start = $range['start']->copy()->tz($tz)->startOfDay();
$end = $range['end']->copy()->tz($tz)->endOfDay();
if (!$now->betweenIncluded($start, $end)) {
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
}
$minutes = ($now->hour * 60) + $now->minute;
$gridStartMinutes = $daytimeHours ? ((int) $daytimeHours['start'] * 60) : 0;
$gridEndMinutes = $daytimeHours ? ((int) $daytimeHours['end'] * 60) : (24 * 60);
if ($daytimeHours && ($minutes < $gridStartMinutes || $minutes >= $gridEndMinutes)) {
return ['show' => false, 'row' => 1, 'day_col' => 1, 'col_start' => 1, 'col_end' => 2];
}
$relativeMinutes = $minutes - $gridStartMinutes;
$relativeMinutes = max(0, min($relativeMinutes, $gridEndMinutes - $gridStartMinutes));
$row = intdiv($relativeMinutes, $minutesPerSlot) + 1;
$offset = ($relativeMinutes % $minutesPerSlot) / $minutesPerSlot;
if ($view === 'day') {
$dayCol = 1;
} else {
$todayStart = $now->copy()->startOfDay();
$dayCol = $start->diffInDays($todayStart) + 1;
}
$dayCol = (int) $dayCol;
return [
'show' => true,
'row' => (int) $row,
'offset' => round($offset, 4),
'day_col' => $dayCol,
'col_start' => $dayCol,
'col_end' => $dayCol + 1,
];
}
/**
* Time-based layout slot placement.
*/
private function slotPlacement(
Carbon $startLocal,
?Carbon $endLocal,
Carbon $rangeStart,
string $view,
int $minutesPerSlot = 15,
int $gridStartMinutes = 0,
int $gridEndMinutes = 1440
): array {
$start = $startLocal->copy();
$end = ($endLocal ?? $startLocal)->copy();
$startMinutes = (($start->hour * 60) + $start->minute) - $gridStartMinutes;
$endMinutes = (($end->hour * 60) + $end->minute) - $gridStartMinutes;
$maxStart = max(0, ($gridEndMinutes - $gridStartMinutes) - $minutesPerSlot);
$startMinutes = $this->snapToSlot($startMinutes, $minutesPerSlot, 0, $maxStart);
$endMinutes = $this->snapToSlot(
$endMinutes,
$minutesPerSlot,
$minutesPerSlot,
max($minutesPerSlot, $gridEndMinutes - $gridStartMinutes)
);
if ($endMinutes <= $startMinutes) {
$endMinutes = min($startMinutes + $minutesPerSlot, $gridEndMinutes - $gridStartMinutes);
}
$startRow = intdiv($startMinutes, $minutesPerSlot) + 1;
$rowSpan = max(1, intdiv($endMinutes - $startMinutes, $minutesPerSlot));
$endRow = $startRow + $rowSpan;
$maxCols = match ($view) {
'day' => 1,
'four' => 4,
'week' => 7,
default => 1,
};
$startCol = $rangeStart->copy()->startOfDay()->diffInDays($start->copy()->startOfDay()) + 1;
$startCol = max(1, min($maxCols, $startCol));
return [
'start_row' => $startRow,
'end_row' => $endRow,
'row_span' => $rowSpan,
'duration' => $endMinutes - $startMinutes,
'start_col' => $startCol,
];
}
private function snapToSlot(int $minutes, int $slot, int $min, int $max): int
{
$rounded = (int) round($minutes / $slot) * $slot;
return max($min, min($rounded, $max));
}
/**
* Apply overlap metadata for time-based views (day/four/week).
*/
private function applyOverlapLayout(Collection $events, string $view): Collection
{
if (!in_array($view, ['day', 'four', 'week'], true) || $events->isEmpty()) {
return $events;
}
$items = $events->all(); // keyed by occurrence_id
$eventsByCol = [];
foreach ($items as $id => $event) {
$col = (int) ($event['start_col'] ?? 1);
$eventsByCol[$col][$id] = $event;
}
foreach ($eventsByCol as $group) {
uasort($group, function (array $a, array $b) {
$cmp = ($a['start_row'] ?? 0) <=> ($b['start_row'] ?? 0);
if ($cmp !== 0) {
return $cmp;
}
return ($a['end_row'] ?? 0) <=> ($b['end_row'] ?? 0);
});
$cluster = [];
$clusterEnd = null;
foreach ($group as $id => $event) {
$startRow = (int) ($event['start_row'] ?? 0);
$endRow = (int) ($event['end_row'] ?? 0);
if ($clusterEnd !== null && $startRow >= $clusterEnd) {
$this->assignOverlapCluster($cluster, $items);
$cluster = [];
$clusterEnd = null;
}
$cluster[$id] = $event;
$clusterEnd = $clusterEnd === null
? $endRow
: max($clusterEnd, $endRow);
}
if ($cluster) {
$this->assignOverlapCluster($cluster, $items);
}
}
return collect($items);
}
private function assignOverlapCluster(array $cluster, array &$items): void
{
if (empty($cluster)) {
return;
}
$active = [];
$availableCols = [];
$assigned = [];
$maxCol = 0;
foreach ($cluster as $id => $event) {
$startRow = (int) ($event['start_row'] ?? 0);
$endRow = (int) ($event['end_row'] ?? 0);
foreach ($active as $idx => $info) {
if ($info['end'] <= $startRow) {
$availableCols[] = $info['col'];
unset($active[$idx]);
}
}
sort($availableCols);
if (!empty($availableCols)) {
$col = array_shift($availableCols);
} else {
$col = $maxCol;
$maxCol++;
}
$assigned[$id] = $col;
$active[] = ['end' => $endRow, 'col' => $col];
}
$totalCols = max(1, $maxCol);
$width = 100 / $totalCols;
foreach ($cluster as $id => $event) {
$index = $assigned[$id] ?? 0;
$items[$id]['overlap_count'] = $totalCols;
$items[$id]['overlap_index'] = $index;
$items[$id]['overlap_width'] = round($width, 4);
$items[$id]['overlap_offset'] = round($width * $index, 4);
$items[$id]['overlap_z'] = $index + 1;
}
}
}

View File

@ -0,0 +1,236 @@
<?php
namespace App\Services\Event;
use App\Models\Event;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Reader;
class EventAttendeeSynchronizer
{
public function syncFromCalendarData(Event $event, ?string $calendarData = null): void
{
$attendees = $this->extractFromCalendarData($calendarData ?? $event->calendardata);
$this->syncRows($event, $attendees);
}
public function syncRows(Event $event, array $attendees): void
{
$rows = $this->normalizeRows($attendees);
$event->attendees()->delete();
if (empty($rows)) {
return;
}
foreach ($rows as &$row) {
$email = $row['email'] ?? null;
if (!$email) {
$row['attendee_user_id'] = null;
continue;
}
$row['attendee_user_id'] = User::query()
->whereRaw('lower(email) = ?', [Str::lower($email)])
->value('id');
}
unset($row);
$event->attendees()->createMany($rows);
}
public function extractFromCalendarData(?string $calendarData): array
{
if (!$calendarData) {
return [];
}
try {
$vcalendar = Reader::read($calendarData);
} catch (\Throwable $e) {
return [];
}
$vevent = $vcalendar->select('VEVENT')[0] ?? null;
if (!$vevent instanceof VEvent) {
return [];
}
return $this->extractFromVevent($vevent);
}
public function extractFromVevent(VEvent $vevent): array
{
$items = [];
foreach ($vevent->select('ATTENDEE') as $attendee) {
$params = $this->extractParams($attendee);
$uri = $this->normalizeAttendeeUri((string) $attendee->getValue());
$email = $this->extractEmail($uri, $params);
$items[] = [
'attendee_uri' => $uri,
'email' => $email,
'name' => $params['CN'] ?? null,
'role' => isset($params['ROLE']) ? strtoupper($params['ROLE']) : null,
'partstat' => isset($params['PARTSTAT']) ? strtoupper($params['PARTSTAT']) : null,
'cutype' => isset($params['CUTYPE']) ? strtoupper($params['CUTYPE']) : null,
'rsvp' => $this->normalizeBooleanParam($params['RSVP'] ?? null),
'is_organizer' => false,
'extra' => $this->extraParams($params, [
'CN',
'ROLE',
'PARTSTAT',
'CUTYPE',
'RSVP',
'EMAIL',
]),
];
}
if (isset($vevent->ORGANIZER)) {
$organizer = $vevent->ORGANIZER;
$params = $this->extractParams($organizer);
$uri = $this->normalizeAttendeeUri((string) $organizer->getValue());
$email = $this->extractEmail($uri, $params);
$items[] = [
'attendee_uri' => $uri,
'email' => $email,
'name' => $params['CN'] ?? null,
'role' => 'CHAIR',
'partstat' => null,
'cutype' => isset($params['CUTYPE']) ? strtoupper($params['CUTYPE']) : null,
'rsvp' => null,
'is_organizer' => true,
'extra' => $this->extraParams($params, ['CN', 'CUTYPE', 'EMAIL']),
];
}
return $items;
}
private function normalizeRows(array $attendees): array
{
$rows = [];
$seen = [];
foreach ($attendees as $item) {
$email = $this->normalizeEmail(Arr::get($item, 'email'));
$uri = $this->normalizeAttendeeUri(Arr::get($item, 'attendee_uri'));
if (!$uri && $email) {
$uri = 'mailto:' . $email;
}
if (!$uri && !$email) {
continue;
}
$key = Str::lower($uri ?: $email);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$role = Arr::get($item, 'role');
$partstat = Arr::get($item, 'partstat');
$cutype = Arr::get($item, 'cutype');
$rows[] = [
'attendee_uri' => $uri,
'email' => $email,
'name' => $this->normalizeString(Arr::get($item, 'name')),
'role' => $role ? strtoupper((string) $role) : null,
'partstat' => $partstat ? strtoupper((string) $partstat) : null,
'cutype' => $cutype ? strtoupper((string) $cutype) : null,
'rsvp' => Arr::exists($item, 'rsvp') ? (bool) Arr::get($item, 'rsvp') : null,
'is_organizer' => (bool) Arr::get($item, 'is_organizer', false),
'extra' => is_array(Arr::get($item, 'extra')) ? Arr::get($item, 'extra') : null,
];
}
return $rows;
}
private function extractParams($property): array
{
$params = [];
foreach ($property->parameters() as $parameter) {
$params[strtoupper((string) $parameter->name)] = trim((string) $parameter->getValue());
}
return $params;
}
private function extractEmail(?string $uri, array $params): ?string
{
$fromParam = $this->normalizeEmail($params['EMAIL'] ?? null);
if ($fromParam) {
return $fromParam;
}
if (!$uri) {
return null;
}
if (str_starts_with(Str::lower($uri), 'mailto:')) {
return $this->normalizeEmail(substr($uri, 7));
}
return str_contains($uri, '@') ? $this->normalizeEmail($uri) : null;
}
private function normalizeAttendeeUri(mixed $uri): ?string
{
$value = trim((string) $uri);
if ($value === '') {
return null;
}
if (str_contains($value, '@') && !str_contains($value, ':')) {
return 'mailto:' . Str::lower($value);
}
if (str_starts_with(Str::lower($value), 'mailto:')) {
return 'mailto:' . Str::lower(substr($value, 7));
}
return $value;
}
private function normalizeEmail(mixed $value): ?string
{
$email = trim((string) $value);
if ($email === '') {
return null;
}
return Str::lower($email);
}
private function normalizeString(mixed $value): ?string
{
$string = trim((string) $value);
return $string === '' ? null : $string;
}
private function normalizeBooleanParam(?string $value): ?bool
{
if ($value === null || $value === '') {
return null;
}
return in_array(strtoupper($value), ['1', 'TRUE', 'YES'], true);
}
private function extraParams(array $params, array $known): ?array
{
$knownMap = array_fill_keys($known, true);
$extra = array_diff_key($params, $knownMap);
return empty($extra) ? null : $extra;
}
}

View File

@ -0,0 +1,298 @@
<?php
namespace App\Services\Event;
use App\Models\Event;
use Carbon\Carbon;
use DateTimeZone;
use Illuminate\Support\Str;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
use Sabre\VObject\Recur\EventIterator;
class EventRecurrence
{
/**
* Build a VCALENDAR string from core fields and optional recurrence.
*/
public function buildCalendar(array $data): string
{
$vcalendar = new VCalendar();
$vcalendar->add('PRODID', '-//Kithkin//Laravel CalDAV//EN');
$vcalendar->add('VERSION', '2.0');
$vcalendar->add('CALSCALE', 'GREGORIAN');
$vevent = $vcalendar->add('VEVENT', []);
$uid = $data['uid'];
$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 ($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]);
$vevent->add('DTEND', $endLocal, ['TZID' => $tzid]);
} else {
$vevent->add('DTSTART', $startUtc->copy()->utc());
$vevent->add('DTEND', $endUtc->copy()->utc());
}
if (!empty($data['summary'])) {
$vevent->add('SUMMARY', $data['summary']);
}
if (!empty($data['description'])) {
$vevent->add('DESCRIPTION', $data['description']);
}
if (!empty($data['location'])) {
$vevent->add('LOCATION', $data['location']);
}
$attendees = $data['attendees'] ?? [];
if (is_array($attendees)) {
$organizerAdded = false;
foreach ($attendees as $attendee) {
if (!is_array($attendee)) {
continue;
}
$uri = $this->normalizeAttendeeUri(
$attendee['attendee_uri'] ?? ($attendee['email'] ?? null)
);
if (!$uri) {
continue;
}
$params = [];
if (!empty($attendee['name'])) {
$params['CN'] = (string) $attendee['name'];
}
if (!empty($attendee['role'])) {
$params['ROLE'] = strtoupper((string) $attendee['role']);
}
if (!empty($attendee['partstat'])) {
$params['PARTSTAT'] = strtoupper((string) $attendee['partstat']);
}
if (!empty($attendee['cutype'])) {
$params['CUTYPE'] = strtoupper((string) $attendee['cutype']);
}
if (array_key_exists('rsvp', $attendee) && $attendee['rsvp'] !== null) {
$params['RSVP'] = (bool) $attendee['rsvp'] ? 'TRUE' : 'FALSE';
}
if (!empty($attendee['extra']) && is_array($attendee['extra'])) {
foreach ($attendee['extra'] as $key => $value) {
if (!is_scalar($value)) {
continue;
}
$paramName = strtoupper((string) $key);
if ($paramName === '' || isset($params[$paramName])) {
continue;
}
$params[$paramName] = (string) $value;
}
}
if ((bool) ($attendee['is_organizer'] ?? false) && !$organizerAdded) {
$organizerParams = $params;
unset($organizerParams['ROLE'], $organizerParams['PARTSTAT'], $organizerParams['RSVP']);
$vevent->add('ORGANIZER', $uri, $organizerParams);
$organizerAdded = true;
continue;
}
$vevent->add('ATTENDEE', $uri, $params);
}
}
$rrule = $data['rrule'] ?? null;
if ($rrule) {
$vevent->add('RRULE', $rrule);
}
$exdates = $data['exdate'] ?? [];
if (!empty($exdates)) {
foreach ($exdates as $ex) {
$dt = Carbon::parse($ex, $tzid ?: 'UTC');
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());
}
}
}
$rdates = $data['rdate'] ?? [];
if (!empty($rdates)) {
foreach ($rdates as $r) {
$dt = Carbon::parse($r, $tzid ?: 'UTC');
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());
}
}
}
return $vcalendar->serialize();
}
/**
* Check if a stored event contains recurrence data.
*/
public function isRecurring(Event $event): bool
{
$extra = $event->meta?->extra ?? [];
if (!empty($extra['rrule'])) {
return true;
}
return Str::contains($event->calendardata ?? '', ['RRULE', 'RDATE', 'EXDATE']);
}
/**
* Expand recurring instances within the requested range.
*
* Returns an array of ['start' => Carbon, 'end' => Carbon, 'recurrence_id' => string|null]
*/
public function expand(Event $event, Carbon $rangeStart, Carbon $rangeEnd): array
{
$vcalendar = $this->readCalendar($event->calendardata);
if (!$vcalendar || empty($vcalendar->VEVENT)) {
return [];
}
$vevent = $vcalendar->VEVENT;
$uid = (string) $vevent->UID;
$startTz = $vevent->DTSTART?->getDateTime()?->getTimezone()
?? new DateTimeZone('UTC');
$iter = new EventIterator($vcalendar, $uid);
$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 ($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,
'recurrence_id' => $startUtc->format('Ymd\\THis\\Z'),
];
$iter->next();
}
return $items;
}
/**
* Resolve a single occurrence by its DTSTART.
*/
public function resolveOccurrence(Event $event, Carbon $occurrenceStart): ?array
{
$rangeStart = $occurrenceStart->copy()->subDay();
$rangeEnd = $occurrenceStart->copy()->addDay();
foreach ($this->expand($event, $rangeStart, $rangeEnd) as $occ) {
if ($occ['start']->equalTo($occurrenceStart)) {
return $occ;
}
}
return null;
}
public function extractRrule(Event $event): ?string
{
$vcalendar = $this->readCalendar($event->calendardata);
if (!$vcalendar || empty($vcalendar->VEVENT)) {
return null;
}
$vevent = $vcalendar->VEVENT;
return isset($vevent->RRULE) ? (string) $vevent->RRULE : null;
}
private function readCalendar(?string $ical): ?VCalendar
{
if (!$ical) {
return null;
}
try {
return Reader::read($ical);
} catch (\Throwable $e) {
return null;
}
}
private function normalizeAttendeeUri(mixed $value): ?string
{
$uri = trim((string) $value);
if ($uri === '') {
return null;
}
if (str_contains($uri, '@') && !str_contains($uri, ':')) {
return 'mailto:' . Str::lower($uri);
}
if (str_starts_with(Str::lower($uri), 'mailto:')) {
return 'mailto:' . Str::lower(substr($uri, 7));
}
return $uri;
}
}

View File

@ -4,11 +4,14 @@ namespace App\Services\Location;
use \App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class Geocoder
{
private bool $missingKeyWarned = false;
public function __construct(private array $cfg = [])
{
$this->cfg = config("services.geocoding");
@ -65,6 +68,54 @@ class Geocoder
return rtrim($this->cfg["arcgis"]["endpoint"], "/");
}
/**
* fetch arcgis api key (log once if missing)
*/
private function arcgisKey(): ?string
{
$key = $this->cfg['arcgis']['api_key'] ?? null;
if (!$key) {
$this->warnMissingKey();
return null;
}
return $key;
}
private function warnMissingKey(): void
{
if ($this->missingKeyWarned) {
return;
}
$this->missingKeyWarned = true;
Log::warning('arcgis api key missing; geocoding disabled');
}
private function arcgisDebugEnabled(): bool
{
return (bool) ($this->cfg['arcgis']['debug'] ?? false);
}
private function logArcgisResponse(string $label, array $params, $res): void
{
if (! $this->arcgisDebugEnabled()) {
return;
}
$safeParams = $params;
if (isset($safeParams['token'])) {
$safeParams['token'] = '***';
}
Log::info("arcgis {$label} response", [
'status' => $res->status(),
'params' => $safeParams,
'body' => $res->body(),
]);
}
/**
* pull a bias from the user (zip -> centroid) and cache it
*/
@ -85,8 +136,13 @@ class Geocoder
return null;
}
$key = $this->arcgisKey();
if (!$key) {
return null;
}
$cacheKey = "geo:bias:zip:{$zip}";
$bias = Cache::remember($cacheKey, now()->addDays(7), function () use ($zip) {
$bias = Cache::remember($cacheKey, now()->addDays(7), function () use ($zip, $key) {
$a = $this->cfg['arcgis'];
$params = [
@ -94,11 +150,12 @@ class Geocoder
'category' => 'Postal',
'maxLocations' => 1,
'f' => 'pjson',
'token' => $a['api_key'],
'token' => $key,
'countryCode' => $a['country_code'] ?? null,
];
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
$this->logArcgisResponse('zip-bias', $params, $res);
if (!$res->ok()) {
Log::info('arcgis zip bias lookup failed', ['zip' => $zip, 'status' => $res->status()]);
return null;
@ -139,17 +196,25 @@ class Geocoder
private function forwardArcgis(string $query, ?array $bias): ?array
{
$a = $this->cfg['arcgis'];
$key = $this->arcgisKey();
if (!$key) {
return null;
}
$params = [
'singleLine' => $query,
'outFields' => $a['out_fields'] ?? '*',
'maxLocations' => (int)($a['max_results'] ?? 5),
'f' => 'pjson',
'token' => $a['api_key'],
'token' => $key,
'category' => $a['categories'] ?? 'POI,Address',
'countryCode' => $a['country_code'] ?? null,
];
if (!empty($a['store'])) {
$params['forStorage'] = 'true';
}
if ($bias && $bias['lat'] && $bias['lon']) {
$params['location'] = $bias['lon'].','.$bias['lat'];
if (!empty($bias['radius_km'])) {
@ -158,6 +223,7 @@ class Geocoder
}
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
$this->logArcgisResponse('forward', $params, $res);
if (!$res->ok()) {
Log::warning('arcgis forward geocode failed', ['status' => $res->status(), 'q' => $query]);
return null;
@ -165,6 +231,9 @@ class Geocoder
$cands = $res->json('candidates', []);
if (!$cands) {
if ($this->arcgisDebugEnabled()) {
Log::info('arcgis forward geocode empty', ['q' => $query]);
}
return null;
}
@ -181,13 +250,23 @@ class Geocoder
private function reverseArcgis(float $lat, float $lon): ?array
{
$a = $this->cfg['arcgis'];
$key = $this->arcgisKey();
if (!$key) {
return null;
}
$params = [
'location' => "{$lon},{$lat}",
'f' => 'pjson',
'token' => $a['api_key'],
'token' => $key,
];
if (!empty($a['store'])) {
$params['forStorage'] = 'true';
}
$res = $this->http()->get($this->arcgisBase().'/reverseGeocode', $params);
$this->logArcgisResponse('reverse', $params, $res);
if (!$res->ok()) {
Log::warning('arcgis reverse geocode failed', ['status' => $res->status()]);
return null;
@ -247,8 +326,10 @@ class Geocoder
return [];
}
$bias = $this->biasForUser($user);
return match ($provider) {
"arcgis" => $this->arcgisSuggestions($query, $limit),
"arcgis" => $this->arcgisSuggestions($query, $limit, $bias),
default => [],
};
}
@ -256,17 +337,34 @@ class Geocoder
/**
* get the suggestions from arcgis
*/
private function arcgisSuggestions(string $query, int $limit): array
private function arcgisSuggestions(string $query, int $limit, ?array $bias = null): array
{
$key = $this->arcgisKey();
if (!$key) {
return [];
}
$params = [
"singleLine" => $query,
"outFields" => $this->cfg["arcgis"]["out_fields"] ?? "*",
"maxLocations" => $limit,
// you can bias results with 'countryCode' or 'location' here if desired
"category" => $this->cfg["arcgis"]["categories"] ?? "POI,Address",
"countryCode" => $this->cfg["arcgis"]["country_code"] ?? null,
"f" => "pjson",
"token" => $this->cfg["arcgis"]["api_key"],
"token" => $key,
];
if ($bias && $bias['lat'] && $bias['lon']) {
$params['location'] = $bias['lon'] . ',' . $bias['lat'];
if (!empty($bias['radius_km'])) {
$params['searchExtent'] = $this->bboxFromBias(
(float) $bias['lat'],
(float) $bias['lon'],
(float) $bias['radius_km']
);
}
}
if (!empty($this->cfg["arcgis"]["store"])) {
$params["forStorage"] = "true";
}
@ -275,6 +373,7 @@ class Geocoder
$this->arcgisBase() . "/findAddressCandidates",
$params,
);
$this->logArcgisResponse('suggestions', $params, $res);
if (!$res->ok()) {
return [];
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\HtmxAwareAuthenticate;
use App\Http\Middleware\SetUserLocale;
use App\Jobs\GeocodeEventLocations;
use App\Jobs\SyncSubscriptionsDispatcher;
@ -21,6 +22,11 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web(append: [
SetUserLocale::class,
]);
// ensure HTMX requests redirect in the top-level window on auth expiry
$middleware->alias([
'auth' => HtmxAwareAuthenticate::class,
]);
})
->withSchedule(function (Schedule $schedule) {

1370
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -39,9 +39,11 @@ 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),
"debug" => (bool) env("ARCGIS_DEBUG", false),
"endpoint" =>
"https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer",
"country_code" => env("GEOCODER_COUNTRY", "USA"),
@ -49,6 +51,8 @@ return [
"out_fields" =>
"Match_addr,Addr_type,PlaceName,Place_addr,Address,City,Region,Postal,CountryCode,LongLabel",
"max_results" => 1,
"basemap_style" => env("ARCGIS_BASEMAP_STYLE", "arcgis/community"),
"basemap_zoom" => (int) env("ARCGIS_BASEMAP_ZOOM", 16),
],
],
];

View File

@ -3,6 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
@ -12,26 +13,38 @@ return new class extends Migration
return base_path("vendor/sabre/dav/examples/sql/{$file}");
}
private function prefix(): string
{
$driver = DB::connection()->getDriverName();
return match ($driver) {
'sqlite' => 'sqlite',
'pgsql' => 'pgsql',
default => 'mysql',
};
}
public function up(): void
{
// Disable FK checks for smooth batch execution
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
$prefix = $this->prefix();
Schema::disableForeignKeyConstraints();
// Principals (users & groups)
DB::unprepared(File::get($this->sql('mysql.principals.sql')));
DB::unprepared(File::get($this->sql("{$prefix}.principals.sql")));
// CalDAV calendars + objects
DB::unprepared(File::get($this->sql('mysql.calendars.sql')));
DB::unprepared(File::get($this->sql("{$prefix}.calendars.sql")));
// CardDAV address books + cards
DB::unprepared(File::get($this->sql('mysql.addressbooks.sql')));
DB::unprepared(File::get($this->sql("{$prefix}.addressbooks.sql")));
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
Schema::enableForeignKeyConstraints();
}
public function down(): void
{
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
$this->prefix();
Schema::disableForeignKeyConstraints();
// Drop in reverse dependency order
DB::statement('DROP TABLE IF EXISTS
@ -47,6 +60,6 @@ return new class extends Migration
groupmembers
');
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
Schema::enableForeignKeyConstraints();
}
};

View File

@ -9,7 +9,9 @@ return new class extends Migration
// add composite + geo + optional fulltext indexes to locations
public function up(): void
{
Schema::table('locations', function (Blueprint $table) {
$driver = Schema::getConnection()->getDriverName();
Schema::table('locations', function (Blueprint $table) use ($driver) {
// composite btree index for common lookups
$table->index(
['display_name', 'city', 'state', 'postal', 'country'],
@ -21,17 +23,23 @@ return new class extends Migration
// optional: fulltext index for free-form text searching
// note: requires mysql/mariadb version with innodb fulltext support
$table->fullText('raw_address', 'locations_raw_address_fulltext');
if (in_array($driver, ['mysql', 'pgsql'], true)) {
$table->fullText('raw_address', 'locations_raw_address_fulltext');
}
});
}
// drop the indexes added in up()
public function down(): void
{
Schema::table('locations', function (Blueprint $table) {
$driver = Schema::getConnection()->getDriverName();
Schema::table('locations', function (Blueprint $table) use ($driver) {
$table->dropIndex('locations_name_city_idx');
$table->dropIndex('locations_lat_lon_idx');
$table->dropFullText('locations_raw_address_fulltext');
if (in_array($driver, ['mysql', 'pgsql'], true)) {
$table->dropFullText('locations_raw_address_fulltext');
}
});
}
};
};

View File

@ -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']);
});
}
};

View File

@ -0,0 +1,47 @@
<?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::create('event_attendees', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('event_id');
$table->foreign('event_id')
->references('id')
->on('calendarobjects')
->cascadeOnDelete();
$table->ulid('attendee_user_id')->nullable();
$table->foreign('attendee_user_id')
->references('id')
->on('users')
->nullOnDelete();
$table->string('attendee_uri', 500)->nullable();
$table->string('email')->nullable();
$table->string('name')->nullable();
$table->string('role', 32)->nullable();
$table->string('partstat', 32)->nullable();
$table->string('cutype', 32)->nullable();
$table->boolean('rsvp')->nullable();
$table->boolean('is_organizer')->default(false);
$table->json('extra')->nullable();
$table->timestamps();
$table->index(['event_id', 'email']);
$table->index('attendee_user_id');
$table->unique(['event_id', 'attendee_uri']);
});
}
public function down(): void
{
Schema::dropIfExists('event_attendees');
}
};

View File

@ -0,0 +1,156 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
return new class extends Migration
{
private array $contacts = [
[
'first' => 'Daniel',
'last' => 'Gioia',
'email' => 'daniel@gioia.email',
'uid' => 'kithkin-contact-daniel-gioia',
],
[
'first' => 'James',
'last' => 'Gioia',
'email' => 'james@gioia.email',
'uid' => 'kithkin-contact-james-gioia',
],
];
public function up(): void
{
foreach ($this->contacts as $contact) {
$email = Str::lower($contact['email']);
$existingId = DB::table('users')->where('email', $email)->value('id');
if ($existingId) {
DB::table('users')
->where('id', $existingId)
->update([
'uri' => 'principals/' . $email,
'firstname' => $contact['first'],
'lastname' => $contact['last'],
'displayname' => trim($contact['first'] . ' ' . $contact['last']),
'timezone' => config('app.timezone', 'UTC'),
'email_verified_at' => now(),
'updated_at' => now(),
]);
} else {
DB::table('users')->insert([
'id' => (string) Str::ulid(),
'uri' => 'principals/' . $email,
'firstname' => $contact['first'],
'lastname' => $contact['last'],
'displayname' => trim($contact['first'] . ' ' . $contact['last']),
'email' => $email,
'timezone' => config('app.timezone', 'UTC'),
'password' => Hash::make((string) Str::uuid()),
'email_verified_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
$ownerEmail = Str::lower((string) env('ADMIN_EMAIL', 'admin@example.com'));
$owner = DB::table('users')->where('email', $ownerEmail)->first();
if (!$owner) {
$owner = DB::table('users')->orderBy('created_at')->first();
}
if (!$owner) {
return;
}
$principal = $owner->uri ?: ('principals/' . $owner->email);
$addressbookId = DB::table('addressbooks')
->where('principaluri', $principal)
->orderBy('id')
->value('id');
if (!$addressbookId) {
$addressbookId = DB::table('addressbooks')->insertGetId([
'principaluri' => $principal,
'uri' => 'default',
'displayname' => 'Default Address Book',
]);
DB::table('addressbook_meta')->updateOrInsert(
['addressbook_id' => $addressbookId],
[
'color' => '#ff40ff',
'is_default' => true,
'settings' => null,
'created_at' => now(),
'updated_at' => now(),
]
);
}
foreach ($this->contacts as $contact) {
$email = Str::lower($contact['email']);
$fn = trim($contact['first'] . ' ' . $contact['last']);
$uid = $contact['uid'];
$vcard = "BEGIN:VCARD\r\n"
. "VERSION:3.0\r\n"
. "FN:{$fn}\r\n"
. "EMAIL:{$email}\r\n"
. "UID:{$uid}\r\n"
. "END:VCARD\r\n";
$existingCardId = DB::table('cards')
->where('addressbookid', $addressbookId)
->where(function ($query) use ($uid, $email) {
$query->where('carddata', 'like', '%UID:' . $uid . '%')
->orWhere('carddata', 'like', '%EMAIL:' . $email . '%');
})
->value('id');
if ($existingCardId) {
DB::table('cards')
->where('id', $existingCardId)
->update([
'lastmodified' => now()->timestamp,
'etag' => md5($vcard),
'size' => strlen($vcard),
'carddata' => $vcard,
]);
continue;
}
DB::table('cards')->insert([
'addressbookid' => $addressbookId,
'uri' => (string) Str::uuid() . '.vcf',
'lastmodified' => now()->timestamp,
'etag' => md5($vcard),
'size' => strlen($vcard),
'carddata' => $vcard,
]);
}
}
public function down(): void
{
foreach ($this->contacts as $contact) {
$email = Str::lower($contact['email']);
$uid = $contact['uid'];
DB::table('cards')
->where(function ($query) use ($uid, $email) {
$query->where('carddata', 'like', '%UID:' . $uid . '%')
->orWhere('carddata', 'like', '%EMAIL:' . $email . '%');
})
->delete();
DB::table('users')
->where('email', $email)
->delete();
}
}
};

View File

@ -8,6 +8,7 @@ use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
use App\Models\User;
use App\Services\Event\EventRecurrence;
class DatabaseSeeder extends Seeder
{
@ -217,6 +218,68 @@ ICS;
);
};
$recurrence = new EventRecurrence();
$insertRecurringEvent = function (
Carbon $start,
string $summary,
string $locationKey,
string $rrule,
string $tz
) use ($calId, $locationIdMap, $locationSeeds, $recurrence) {
$uid = Str::uuid().'@'.parse_url(config('app.url'), PHP_URL_HOST);
$end = $start->copy()->addHour();
$startUtc = $start->copy()->utc();
$endUtc = $end->copy()->utc();
$locationDisplay = $locationKey;
$locationRaw = $locationSeeds[$locationKey]['raw'] ?? null;
$icalLocation = $locationRaw ?? $locationDisplay;
$ical = $recurrence->buildCalendar([
'uid' => $uid,
'start_utc' => $startUtc,
'end_utc' => $endUtc,
'summary' => $summary,
'description' => 'Automatically seeded recurring event',
'location' => $icalLocation,
'tzid' => $tz,
'rrule' => $rrule,
]);
$eventId = DB::table('calendarobjects')->insertGetId([
'calendarid' => $calId,
'uri' => Str::uuid().'.ics',
'lastmodified' => time(),
'etag' => md5($ical),
'size' => strlen($ical),
'componenttype' => 'VEVENT',
'uid' => $uid,
'calendardata' => $ical,
]);
DB::table('event_meta')->updateOrInsert(
['event_id' => $eventId],
[
'title' => $summary,
'description' => 'Automatically seeded recurring event',
'location' => $locationRaw ? null : $locationDisplay,
'location_id' => $locationIdMap[$locationKey] ?? null,
'all_day' => false,
'category' => 'Demo',
'start_at' => $startUtc,
'end_at' => $endUtc,
'extra' => json_encode([
'rrule' => $rrule,
'tzid' => $tz,
]),
'created_at' => now(),
'updated_at' => now(),
]
);
};
/**
*
* create events
@ -226,7 +289,7 @@ ICS;
$now = Carbon::now($tz)->setSeconds(0);
// 3 events today
$insertEvent($now->copy(), 'Playground with James', 'McCaHill Park');
$insertEvent($now->copy(), 'Playground with James', 'McCahill Park');
$insertEvent($now->copy()->addHours(2), 'Lunch with Daniel', 'Home');
$insertEvent($now->copy()->addHours(4), 'Baseball practice', 'Meadow Park');
@ -244,6 +307,23 @@ ICS;
$insertEvent($future5a, 'Teacher conference (3rd grade)', 'Fairview Elementary');
$insertEvent($future5b, 'Family game night', 'Living Room');
// overlapping events 3 days after "now"
$overlapDay = $now->copy()->addDays(3)->setTime(9, 0);
$insertEvent($overlapDay->copy(), 'Overlap: Daily standup', 'Home');
$insertEvent($overlapDay->copy()->addMinutes(15), 'Overlap: Design review', 'Living Room');
$insertEvent($overlapDay->copy()->addMinutes(30), 'Overlap: Vendor call', 'McCahill Park');
$insertEvent($overlapDay->copy()->addMinutes(45), 'Overlap: Planning session', 'Meadow Park');
// recurring: weekly on Mon/Wed for 8 weeks at 6:30pm
$recurringStart = $now->copy()->next(Carbon::MONDAY)->setTime(18, 30);
$insertRecurringEvent(
$recurringStart,
'Evening run',
'McCahill Park',
'FREQ=WEEKLY;BYDAY=MO,WE;COUNT=16',
$tz
);
/**
*
* address books

View File

@ -16,6 +16,10 @@ return [
'create' => 'Create calendar',
'description' => 'Description',
'ics' => [
'share' => 'Share this calendar publicly',
'share_help' => 'Anyone with this link can subscribe to your calendar.',
'public_url' => 'Public subscription URL',
'public_url_help' => 'Copy this URL into a third-party calendar app to subscribe.',
'url' => 'ICS URL',
'url_help' => 'You can\'t edit a public calendar URL. If you need to make a change, unsubscribe and add it again.',
],
@ -46,6 +50,86 @@ return [
'saved' => 'Your calendar settings have been saved!',
'title' => 'Calendar settings',
],
'timezone_help' => 'You can override your default time zone here.'
'timezone_help' => 'You can override your default time zone here.',
'toggle_sidebar' => 'Toggle calendar sidebar',
'event' => [
'when' => 'When',
'all_day' => 'All day',
'location' => 'Location',
'map_coming' => 'Map preview coming soon.',
'map_needs_key' => 'Map preview requires an ArcGIS basemap API key.',
'map_attribution' => 'Basemap tiles © Esri and the GIS User Community.',
'no_location' => 'No location set.',
'details' => 'Details',
'repeats' => 'Repeats',
'does_not_repeat' => 'Does not repeat',
'category' => 'Category',
'none' => 'None',
'visibility' => 'Visibility',
'private' => 'Private',
'default' => 'Default',
'all_day_handling' => 'All-day handling',
'timed' => 'Timed',
'all_day_coming' => 'Multi-day all-day UI coming soon',
'alerts' => 'Alerts',
'reminder' => 'Reminder',
'minutes_before' => 'minutes before',
'alerts_coming' => 'No alerts set. (Coming soon)',
'invitees' => 'Invitees',
'invitees_coming' => 'Invitees and RSVP tracking coming soon.',
'attendees' => [
'add' => 'Add attendee',
'add_button' => 'Add',
'remove' => 'Remove',
'help' => 'Search your contacts or enter an email address.',
'search_placeholder' => 'Search contacts or type email',
'verified' => 'Verified Kithkin user',
'optional' => 'Optional',
'rsvp' => 'Request RSVP',
],
'attachments' => 'Attachments',
'attachments_coming' => 'Attachment support coming soon.',
'notes' => 'Notes',
'no_description' => 'No description yet.',
'all_day_events' => 'All-day events',
'show_more' => ':count more',
'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',
],
],
],
];

68
lang/it/account.php Normal file
View File

@ -0,0 +1,68 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Account Language Lines
|--------------------------------------------------------------------------
|
| Account, profile, and user settings language lines.
|
*/
// addresses
'address' => [
'city' => 'Citta',
'country' => 'Paese',
'home' => 'Indirizzo di casa',
'label' => 'Etichetta indirizzo',
'line1' => 'Indirizzo riga 1',
'line2' => 'Indirizzo riga 2',
'state' => 'Provincia',
'work' => 'Indirizzo di lavoro',
'zip' => 'CAP',
],
'billing' => [
'home' => 'Usa il tuo indirizzo di casa per la fatturazione',
'work' => 'Usa il tuo indirizzo di lavoro per la fatturazione',
],
'delete' => 'Elimina account',
'delete-your' => 'Elimina il tuo account',
'delete-confirm' => 'Elimina davvero il mio account!',
'email' => 'Email',
'email_address' => 'Indirizzo email',
'first_name' => 'Nome',
'last_name' => 'Cognome',
'phone' => 'Numero di telefono',
'settings' => [
'addresses' => [
'title' => 'Indirizzi',
'subtitle' => 'Gestisci i tuoi indirizzi di casa e lavoro e scegli quale usare per la fatturazione.',
],
'delete' => [
'title' => 'Qui ci sono draghi',
'subtitle' => 'Elimina il tuo account e rimuovi tutte le informazioni dal nostro database. Non puo essere annullato, quindi consigliamo di esportare i tuoi dati prima e migrare a un nuovo provider.',
'explanation' => 'Nota: non e come altre app che "eliminano" i dati&mdash;non stiamo impostando <code>is_deleted = 1</code>, li stiamo rimuovendo dal nostro database.',
],
'delete-confirm' => [
'title' => 'Conferma eliminazione account',
'subtitle' => 'Inserisci la tua password e conferma che vuoi eliminare definitivamente il tuo account.',
],
'information' => [
'title' => 'Informazioni personali',
'subtitle' => 'Il tuo nome, email e altri dettagli principali del account.',
],
'locale' => [
'title' => 'Preferenze locali',
'subtitle' => 'Posizione, fuso orario e altre preferenze regionali per calendari ed eventi.'
],
'password' => [
'title' => 'Password',
'subtitle' => 'Assicurati che il tuo account usi una password lunga e casuale per restare sicuro. Consigliamo anche un password manager!',
],
'title' => 'Impostazioni account',
],
'title' => 'Account',
];

20
lang/it/auth.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'Queste credenziali non corrispondono ai nostri record.',
'password' => 'La password fornita non e corretta.',
'throttle' => 'Troppi tentativi di accesso. Riprova tra :seconds secondi.',
];

135
lang/it/calendar.php Normal file
View File

@ -0,0 +1,135 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Calendar Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used throughout the calendar app,
| including calendar settings and events.
|
*/
'color' => 'Colore',
'create' => 'Crea calendario',
'description' => 'Descrizione',
'ics' => [
'share' => 'Condividi questo calendario pubblicamente',
'share_help' => 'Chiunque abbia questo link puo iscriversi al tuo calendario.',
'public_url' => 'URL pubblico di iscrizione',
'public_url_help' => 'Copia questo URL in un app calendario di terze parti per iscriverti.',
'url' => 'URL ICS',
'url_help' => 'Non puoi modificare un URL di calendario pubblico. Se devi fare una modifica, annulla l iscrizione e aggiungilo di nuovo.',
],
'mine' => 'I miei calendari',
'name' => 'Nome calendario',
'settings' => [
'calendar' => [
'title' => 'Impostazioni calendario',
'subtitle' => 'Dettagli e impostazioni per <strong>:calendar</strong>.'
],
'create' => [
'title' => 'Crea un calendario',
'subtitle' => 'Crea un nuovo calendario locale.',
],
'display' => [
'title' => 'Preferenze di visualizzazione',
'subtitle' => 'Regola aspetto e comportamento dei tuoi calendari.'
],
'language_region' => [
'title' => 'Lingua e regione',
'subtitle' => 'Scegli la lingua predefinita, la regione e le preferenze di formattazione. Queste influenzano come date e orari sono mostrati nei calendari e negli eventi.',
],
'my_calendars' => 'Impostazioni per i miei calendari',
'subscribe' => [
'title' => 'Iscriviti a un calendario',
'subtitle' => 'Aggiungi un calendario `.ics` da un altro servizio',
],
'saved' => 'Le impostazioni del calendario sono state salvate!',
'title' => 'Impostazioni calendario',
],
'timezone_help' => 'Puoi sovrascrivere il tuo fuso orario predefinito qui.',
'toggle_sidebar' => 'Mostra o nascondi la barra laterale del calendario',
'event' => [
'when' => 'Quando',
'all_day' => 'Tutto il giorno',
'location' => 'Luogo',
'map_coming' => 'Anteprima mappa in arrivo.',
'map_needs_key' => 'Anteprima mappa richiede una chiave API ArcGIS.',
'map_attribution' => 'Tessere mappa © Esri e la comunita GIS.',
'no_location' => 'Nessun luogo impostato.',
'details' => 'Dettagli',
'repeats' => 'Ripete',
'does_not_repeat' => 'Non si ripete',
'category' => 'Categoria',
'none' => 'Nessuno',
'visibility' => 'Visibilita',
'private' => 'Privato',
'default' => 'Predefinito',
'all_day_handling' => 'Gestione giornata intera',
'timed' => 'Con orario',
'all_day_coming' => 'UI giornate intere multi-giorno in arrivo',
'alerts' => 'Avvisi',
'reminder' => 'Promemoria',
'minutes_before' => 'minuti prima',
'alerts_coming' => 'Nessun avviso impostato. (In arrivo)',
'invitees' => 'Invitati',
'invitees_coming' => 'Invitati e RSVP in arrivo.',
'attendees' => [
'add' => 'Aggiungi invitato',
'add_button' => 'Aggiungi',
'remove' => 'Rimuovi',
'help' => 'Cerca nei tuoi contatti o inserisci un indirizzo email.',
'search_placeholder' => 'Cerca contatti o digita email',
'verified' => 'Utente Kithkin verificato',
'optional' => 'Opzionale',
'rsvp' => 'Richiedi RSVP',
],
'attachments' => 'Allegati',
'attachments_coming' => 'Supporto allegati in arrivo.',
'notes' => 'Note',
'no_description' => 'Nessuna descrizione.',
'all_day_events' => 'Eventi di tutto il giorno',
'show_more' => 'Mostra altri :count',
'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',
],
],
],
];

View File

@ -2,10 +2,41 @@
return [
/*
|--------------------------------------------------------------------------
| Common words and phrases
|--------------------------------------------------------------------------
|
| Generic words used throughout the app in more than one location.
|
*/
'address' => 'Indirizzo',
'addresses' => 'Indirizzi',
'calendar' => 'Calendario',
'calendars' => 'Calendari',
'cancel' => 'Annulla',
'cancel_back' => 'Annulla e torna indietro',
'cancel_funny' => 'Portami via',
'date' => 'Data',
'date_select' => 'Seleziona una data',
'date_format' => 'Formato data',
'date_format_select' => 'Seleziona un formato data',
'event' => 'Evento',
'events' => 'Eventi',
'language' => 'Lingua',
'language_select' => 'Seleziona una lingua',
'password' => 'Password',
'region' => 'Regione',
'region_select' => 'Seleziona una regione',
'save_changes' => 'Salva modifiche',
'settings' => 'Impostazioni',
'time' => 'Ora',
'time_select' => 'Seleziona un orario',
'time_format' => 'Formato ora',
'time_format_select' => 'Seleziona un formato ora',
'timezone' => 'Fuso orario',
'timezone_default' => 'Fuso orario predefinito',
'timezone_select' => 'Seleziona un fuso orario',
];

19
lang/it/pagination.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Precedente',
'next' => 'Successivo &raquo;',
];

22
lang/it/passwords.php Normal file
View File

@ -0,0 +1,22 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| outcome such as failure due to an invalid password / reset token.
|
*/
'reset' => 'La tua password e stata reimpostata.',
'sent' => 'Ti abbiamo inviato via email il link per reimpostare la password.',
'throttled' => 'Attendi prima di riprovare.',
'token' => 'Questo token di reimpostazione password non e valido.',
'user' => 'Non troviamo un utente con questo indirizzo email.',
];

198
lang/it/validation.php Normal file
View File

@ -0,0 +1,198 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'Il campo :attribute deve essere accettato.',
'accepted_if' => 'Il campo :attribute deve essere accettato quando :other e :value.',
'active_url' => 'Il campo :attribute deve essere un URL valido.',
'after' => 'Il campo :attribute deve essere una data successiva a :date.',
'after_or_equal' => 'Il campo :attribute deve essere una data successiva o uguale a :date.',
'alpha' => 'Il campo :attribute deve contenere solo lettere.',
'alpha_dash' => 'Il campo :attribute deve contenere solo lettere, numeri, trattini e underscore.',
'alpha_num' => 'Il campo :attribute deve contenere solo lettere e numeri.',
'any_of' => 'Il campo :attribute non e valido.',
'array' => 'Il campo :attribute deve essere un array.',
'ascii' => 'Il campo :attribute deve contenere solo caratteri alfanumerici a singolo byte e simboli.',
'before' => 'Il campo :attribute deve essere una data precedente a :date.',
'before_or_equal' => 'Il campo :attribute deve essere una data precedente o uguale a :date.',
'between' => [
'array' => 'Il campo :attribute deve avere tra :min e :max elementi.',
'file' => 'Il campo :attribute deve essere tra :min e :max kilobyte.',
'numeric' => 'Il campo :attribute deve essere tra :min e :max.',
'string' => 'Il campo :attribute deve essere tra :min e :max caratteri.',
],
'boolean' => 'Il campo :attribute deve essere vero o falso.',
'can' => 'Il campo :attribute contiene un valore non autorizzato.',
'confirmed' => 'La conferma del campo :attribute non corrisponde.',
'contains' => 'Il campo :attribute non contiene un valore richiesto.',
'current_password' => 'La password inserita non e corretta.',
'date' => 'Il campo :attribute deve essere una data valida.',
'date_equals' => 'Il campo :attribute deve essere una data uguale a :date.',
'date_format' => 'Il campo :attribute deve corrispondere al formato :format.',
'decimal' => 'Il campo :attribute deve avere :decimal decimali.',
'declined' => 'Il campo :attribute deve essere rifiutato.',
'declined_if' => 'Il campo :attribute deve essere rifiutato quando :other e :value.',
'different' => 'Il campo :attribute e :other devono essere diversi.',
'digits' => 'Il campo :attribute deve essere di :digits cifre.',
'digits_between' => 'Il campo :attribute deve essere tra :min e :max cifre.',
'dimensions' => 'Il campo :attribute ha dimensioni immagine non valide.',
'distinct' => 'Il campo :attribute ha un valore duplicato.',
'doesnt_end_with' => 'Il campo :attribute non deve terminare con uno dei seguenti: :values.',
'doesnt_start_with' => 'Il campo :attribute non deve iniziare con uno dei seguenti: :values.',
'email' => 'Il campo :attribute deve essere un indirizzo email valido.',
'ends_with' => 'Il campo :attribute deve terminare con uno dei seguenti: :values.',
'enum' => 'Il valore selezionato per :attribute non e valido.',
'exists' => 'Il valore selezionato per :attribute non e valido.',
'extensions' => 'Il campo :attribute deve avere una delle seguenti estensioni: :values.',
'file' => 'Il campo :attribute deve essere un file.',
'filled' => 'Il campo :attribute deve avere un valore.',
'gt' => [
'array' => 'Il campo :attribute deve avere piu di :value elementi.',
'file' => 'Il campo :attribute deve essere maggiore di :value kilobyte.',
'numeric' => 'Il campo :attribute deve essere maggiore di :value.',
'string' => 'Il campo :attribute deve essere maggiore di :value caratteri.',
],
'gte' => [
'array' => 'Il campo :attribute deve avere :value elementi o piu.',
'file' => 'Il campo :attribute deve essere maggiore o uguale a :value kilobyte.',
'numeric' => 'Il campo :attribute deve essere maggiore o uguale a :value.',
'string' => 'Il campo :attribute deve essere maggiore o uguale a :value caratteri.',
],
'hex_color' => 'Il campo :attribute deve essere un colore esadecimale valido.',
'image' => 'Il campo :attribute deve essere una immagine.',
'in' => 'Il valore selezionato per :attribute non e valido.',
'in_array' => 'Il campo :attribute deve esistere in :other.',
'in_array_keys' => 'Il campo :attribute deve contenere almeno una delle seguenti chiavi: :values.',
'integer' => 'Il campo :attribute deve essere un numero intero.',
'ip' => 'Il campo :attribute deve essere un indirizzo IP valido.',
'ipv4' => 'Il campo :attribute deve essere un indirizzo IPv4 valido.',
'ipv6' => 'Il campo :attribute deve essere un indirizzo IPv6 valido.',
'json' => 'Il campo :attribute deve essere una stringa JSON valida.',
'list' => 'Il campo :attribute deve essere una lista.',
'lowercase' => 'Il campo :attribute deve essere in minuscolo.',
'lt' => [
'array' => 'Il campo :attribute deve avere meno di :value elementi.',
'file' => 'Il campo :attribute deve essere minore di :value kilobyte.',
'numeric' => 'Il campo :attribute deve essere minore di :value.',
'string' => 'Il campo :attribute deve essere minore di :value caratteri.',
],
'lte' => [
'array' => 'Il campo :attribute non deve avere piu di :value elementi.',
'file' => 'Il campo :attribute deve essere minore o uguale a :value kilobyte.',
'numeric' => 'Il campo :attribute deve essere minore o uguale a :value.',
'string' => 'Il campo :attribute deve essere minore o uguale a :value caratteri.',
],
'mac_address' => 'Il campo :attribute deve essere un indirizzo MAC valido.',
'max' => [
'array' => 'Il campo :attribute non deve avere piu di :max elementi.',
'file' => 'Il campo :attribute non deve essere maggiore di :max kilobyte.',
'numeric' => 'Il campo :attribute non deve essere maggiore di :max.',
'string' => 'Il campo :attribute non deve essere maggiore di :max caratteri.',
],
'max_digits' => 'Il campo :attribute non deve avere piu di :max cifre.',
'mimes' => 'Il campo :attribute deve essere un file di tipo: :values.',
'mimetypes' => 'Il campo :attribute deve essere un file di tipo: :values.',
'min' => [
'array' => 'Il campo :attribute deve avere almeno :min elementi.',
'file' => 'Il campo :attribute deve essere almeno :min kilobyte.',
'numeric' => 'Il campo :attribute deve essere almeno :min.',
'string' => 'Il campo :attribute deve essere almeno :min caratteri.',
],
'min_digits' => 'Il campo :attribute deve avere almeno :min cifre.',
'missing' => 'Il campo :attribute deve essere assente.',
'missing_if' => 'Il campo :attribute deve essere assente quando :other e :value.',
'missing_unless' => 'Il campo :attribute deve essere assente a meno che :other sia :value.',
'missing_with' => 'Il campo :attribute deve essere assente quando :values e presente.',
'missing_with_all' => 'Il campo :attribute deve essere assente quando :values sono presenti.',
'multiple_of' => 'Il campo :attribute deve essere un multiplo di :value.',
'not_in' => 'Il valore selezionato per :attribute non e valido.',
'not_regex' => 'Il formato del campo :attribute non e valido.',
'numeric' => 'Il campo :attribute deve essere un numero.',
'password' => [
'letters' => 'Il campo :attribute deve contenere almeno una lettera.',
'mixed' => 'Il campo :attribute deve contenere almeno una lettera maiuscola e una minuscola.',
'numbers' => 'Il campo :attribute deve contenere almeno un numero.',
'symbols' => 'Il campo :attribute deve contenere almeno un simbolo.',
'uncompromised' => 'Il valore :attribute e apparso in una violazione di dati. Scegli un altro :attribute.',
],
'present' => 'Il campo :attribute deve essere presente.',
'present_if' => 'Il campo :attribute deve essere presente quando :other e :value.',
'present_unless' => 'Il campo :attribute deve essere presente a meno che :other sia :value.',
'present_with' => 'Il campo :attribute deve essere presente quando :values e presente.',
'present_with_all' => 'Il campo :attribute deve essere presente quando :values sono presenti.',
'prohibited' => 'Il campo :attribute e proibito.',
'prohibited_if' => 'Il campo :attribute e proibito quando :other e :value.',
'prohibited_if_accepted' => 'Il campo :attribute e proibito quando :other e accettato.',
'prohibited_if_declined' => 'Il campo :attribute e proibito quando :other e rifiutato.',
'prohibited_unless' => 'Il campo :attribute e proibito a meno che :other sia in :values.',
'prohibits' => 'Il campo :attribute impedisce la presenza di :other.',
'regex' => 'Il formato del campo :attribute non e valido.',
'required' => 'Il campo :attribute e obbligatorio.',
'required_array_keys' => 'Il campo :attribute deve contenere voci per: :values.',
'required_if' => 'Il campo :attribute e obbligatorio quando :other e :value.',
'required_if_accepted' => 'Il campo :attribute e obbligatorio quando :other e accettato.',
'required_if_declined' => 'Il campo :attribute e obbligatorio quando :other e rifiutato.',
'required_unless' => 'Il campo :attribute e obbligatorio a meno che :other sia in :values.',
'required_with' => 'Il campo :attribute e obbligatorio quando :values e presente.',
'required_with_all' => 'Il campo :attribute e obbligatorio quando :values sono presenti.',
'required_without' => 'Il campo :attribute e obbligatorio quando :values non e presente.',
'required_without_all' => 'Il campo :attribute e obbligatorio quando nessuno di :values e presente.',
'same' => 'Il campo :attribute deve corrispondere a :other.',
'size' => [
'array' => 'Il campo :attribute deve contenere :size elementi.',
'file' => 'Il campo :attribute deve essere di :size kilobyte.',
'numeric' => 'Il campo :attribute deve essere :size.',
'string' => 'Il campo :attribute deve essere di :size caratteri.',
],
'starts_with' => 'Il campo :attribute deve iniziare con uno dei seguenti: :values.',
'string' => 'Il campo :attribute deve essere una stringa.',
'timezone' => 'Il campo :attribute deve essere un fuso orario valido.',
'unique' => 'Il valore :attribute e gia stato preso.',
'uploaded' => 'Il campo :attribute non e riuscito a caricare.',
'uppercase' => 'Il campo :attribute deve essere in maiuscolo.',
'url' => 'Il campo :attribute deve essere un URL valido.',
'ulid' => 'Il campo :attribute deve essere un ULID valido.',
'uuid' => 'Il campo :attribute deve essere un UUID valido.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];

View File

@ -10,11 +10,13 @@
@import './lib/calendar.css';
@import './lib/checkbox.css';
@import './lib/color.css';
@import './lib/event.css';
@import './lib/icon.css';
@import './lib/indicator.css';
@import './lib/input.css';
@import './lib/mini.css';
@import './lib/modal.css';
@import './lib/tabs.css';
@import './lib/toast.css';
/** plugins */

View File

@ -98,6 +98,7 @@ body {
main {
@apply overflow-hidden rounded-lg;
max-height: calc(100dvh - 1rem);
container: main / inline-size;
/* app */
body#app & {
@ -182,15 +183,23 @@ main {
/* main content wrapper */
article {
@apply bg-white grid grid-cols-1 ml-2 rounded-md;
@apply bg-white grid grid-cols-1 ml-2 rounded-md z-2;
@apply overflow-y-auto;
grid-template-rows: 5rem auto;
container: content / inline-size;
/* specific animation sets */
&#calendar {
transition:
margin 250ms ease-in-out,
width 250ms ease-in-out,
padding 250ms ease-in-out;
}
/* main content title and actions */
> header {
header {
@apply flex flex-row items-center justify-between w-full;
@apply bg-white sticky top-0 z-10;
@apply bg-white sticky top-0 z-20;
/* app hedar; if h1 exists it means there's no aside, so force the width from that */
h1 {
@ -208,9 +217,19 @@ main {
}
}
/* expand button */
button.calendar-expand-toggle {
svg {
transition: transform 150ms ease-in-out;
}
}
/* header menu */
menu {
@apply flex flex-row items-center justify-end gap-4;
@apply fixed right-0 top-2 flex flex-col bg-gray-100 gap-6 p-6 rounded-l-xl;
height: calc(100dvh - 0.5rem);
width: 33dvw;
display: none;
}
}
@ -242,11 +261,40 @@ main {
/* section specific */
&#calendar {
/* */
header {
.calendar-expand-toggle {
@apply ml-1 opacity-0 pointer-events-none transition-opacity duration-150;
}
h2:hover .calendar-expand-toggle,
h2:focus-within .calendar-expand-toggle {
@apply opacity-100 pointer-events-auto;
}
}
}
&#settings {
/* */
}
/* main content */
section {
/* content footer defaults */
footer {
transition: padding 250ms ease-in-out;
}
}
}
/* expanded */
&.expanded {
button.calendar-expand-toggle {
svg {
transform: rotate(180deg);
}
}
}
}
@ -275,13 +323,29 @@ main {
* desktop handling
*/
/* show app nav on the left at md */
@media (width >= theme(--breakpoint-md)) {
/* menu handling */
@container content (width >= 64rem)
{
main {
article {
header {
menu {
@apply relative top-auto right-auto h-auto w-auto rounded-none bg-transparent;
@apply flex flex-row items-center justify-end gap-4 p-0;
}
}
}
}
}
/* default desktop handling */
@media (width >= theme(--breakpoint-md))
{
body#app {
grid-template-columns: 5rem auto;
grid-template-rows: 1fr 0;
/* show app nav on the left at md */
> nav {
@apply relative w-20 flex-col items-center justify-between px-0 pb-0 order-0;
@ -308,14 +372,21 @@ main {
}
}
/* main content with and without asides */
main {
&:has(aside) {
grid-template-columns: minmax(16rem, 20dvw) auto;
grid-template-columns: max(16rem, 20dvw) auto;
grid-template-rows: 1fr;
}
aside {
@apply bg-white overflow-y-auto h-full;
@apply bg-white overflow-y-auto h-full min-w-48;
transition:
translate 250ms ease-in-out,
visibility 250ms ease-in-out,
opacity 250ms ease-in-out,
scale 250ms ease-in-out;
> h1 {
@apply backdrop-blur-xs sticky top-0 z-1 shrink-0 h-20 min-h-20;
@ -325,6 +396,32 @@ main {
article {
@apply w-full ml-0 pl-3 2xl:pl-4 pr-6 2xl:pr-8 rounded-l-none rounded-r-lg;
&#calendar {
@apply pl-0;
}
}
/* when the calendar is expanded and aside is gone */
&.expanded {
aside {
@apply translate-x-8 invisible opacity-0 scale-y-95;
}
article {
@apply pl-6 pr-6;
margin-left: min(-16rem, -20dvw) !important;
width: 100cqw !important;
&#calendar {
@apply pl-6;
}
}
footer {
@apply pb-3;
}
}
}
}
@ -332,6 +429,13 @@ main {
/* increase size of some things at 2xl */
@media (width >= theme(--breakpoint-2xl)) { /* 96rem */
body#app main {
aside {
h1 {
@apply h-22;
}
}
}
main {
aside {
> h1 {

View File

@ -74,6 +74,8 @@
--outline-width-md: 1.5px;
--background-image-scrollbar: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1));
--radius-xs: 0.25rem;
--radius-sm: 0.375rem;
--radius-md: 0.6667rem;

View File

@ -30,6 +30,17 @@ details {
}
}
/* simple variant */
&.details--simple
{
summary {
&::before,
&::after {
content: none;
}
}
}
&[open] {
summary::after {
@apply rotate-180;

View File

@ -63,6 +63,10 @@ button,
&.button--sm {
@apply text-base px-2 h-8;
> svg {
@apply w-4 h-4;
}
}
}
@ -74,11 +78,19 @@ 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;
@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;
&.button--icon {
@apply rounded-none h-full top-0;
&:hover {
@apply bg-cyan-200;
}
}
&:hover {
@apply bg-cyan-200;
}
@ -142,6 +154,10 @@ button,
/* small */
&.button-group--sm {
@apply h-9 max-h-9 text-sm;
@apply h-9 max-h-9;
> label {
@apply text-sm;
}
}
}

View File

@ -1,9 +1,19 @@
/**
* calendar
*
* z-index: top is currently 10; overlapping events increment, assuming this won't be 10!
*/
/**
* month view
**/
*/
.calendar.month {
@apply grid col-span-3 pb-6 2xl:pb-8 pt-2;
grid-template-rows: 2rem 1fr;
/*grid-template-rows: 2rem 1fr; */
/* force month container to fit window */
grid-template-rows: 2rem calc(100% - 2rem);
overflow-y: hidden;
hgroup {
@apply grid grid-cols-7 w-full gap-1;
@ -17,12 +27,17 @@
@apply grid grid-cols-7 w-full gap-1;
contain: paint;
grid-auto-rows: 1fr;
/*max-height: var(--month-calendar-height); */
/* day element */
li {
@apply relative px-1 pt-8 border-t-md border-gray-900;
@apply bg-white relative px-1 pt-8 border-t-md border-gray-900 overflow-y-auto;
transition: scale 150ms ease-in-out;
/* day number */
&::before {
@apply absolute top-0 right-px w-auto h-8 flex items-center justify-end pr-4 text-sm font-medium;
@apply sticky top-0 -mt-8 -translate-y-8 right-0 w-auto h-8 z-1;
@apply flex items-center justify-end pr-3 text-sm font-medium bg-inherit;
content: attr(data-day-number);
}
@ -46,6 +61,45 @@
@apply rounded-br-lg;
}*/
/* progressive "show more" button */
div.more-events {
@apply absolute bottom-0 h-6 bg-inherit z-2 flex items-center;
width: calc(100% - 0.5rem);
}
button.day-more {
@apply text-xs px-1 h-4;
}
&[data-event-visible="0"] {
.event:nth-child(n+1) { @apply hidden; }
}
&[data-event-visible="1"] {
.event:nth-child(n+2) { @apply hidden; }
}
&[data-event-visible="2"] {
.event:nth-child(n+3) { @apply hidden; }
}
&[data-event-visible="3"] {
.event:nth-child(n+4) { @apply hidden; }
}
&[data-event-visible="4"] {
.event:nth-child(n+5) { @apply hidden; }
}
&[data-event-visible="5"] {
.event:nth-child(n+6) { @apply hidden; }
}
&[data-event-visible="6"] {
.event:nth-child(n+7) { @apply hidden; }
}
&[data-event-visible="7"] {
.event:nth-child(n+8) { @apply hidden; }
}
&[data-event-visible="8"] {
.event:nth-child(n+9) { @apply hidden; }
}
&[data-event-visible="9"] {
.event:nth-child(n+10) { @apply hidden; }
}
/* events */
.event {
@apply flex items-center text-xs gap-1 px-1 py-px font-medium truncate rounded-sm bg-transparent;
@ -72,6 +126,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;
}
}
}
}
}
@ -82,16 +158,22 @@
.calendar.time {
@apply grid;
grid-template-columns: 6rem auto;
grid-template-rows: 4.5rem auto 5rem;
grid-template-rows: 5rem auto 5rem;
--row-height: 2.5rem;
--now-row: 1;
--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;
top: 5.5rem;
@apply bg-white col-span-2 border-b-2 border-primary pl-24 sticky z-11;
@apply top-20;
span.name {
@apply font-semibold uppercase text-sm;
@ -107,11 +189,11 @@
}
div.day-header {
@apply relative flex flex-col gap-2px justify-start items-start;
@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 {
@apply block w-px bg-gray-200 absolute -right-2 top-18;
@apply block w-px bg-gray-200 absolute -right-2 top-20;
content: '';
height: calc(100dvh - 16rem);
}
@ -128,10 +210,70 @@
}
}
/* all day bar */
ol.day {
@apply sticky top-40 grid col-span-2 bg-white border-b border-primary z-10 overflow-x-hidden;
box-shadow: 0 0.25rem 0.5rem -0.25rem rgba(0,0,0,0.15);
padding: 0.25rem 0 0.2rem 6rem;
&::before {
@apply absolute left-0 top-1.5 w-24 pr-4 text-right;
@apply uppercase text-xs font-mono text-secondary font-medium;
content: 'All day';
}
li.event-wrapper {
@apply flex 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);
&.event-wrapper--overflow {
@apply hidden;
}
a.event {
@apply flex items-center text-xs gap-1 px-1 py-px mb-2px 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%);
}
}
&.event-wrapper--more {
label.event--more {
@apply text-xs cursor-pointer rounded m-1 h-3.5 leading-none;
@apply focus:outline-2 focus:outline-offset-2 focus:outline-cyan-600;
input.more-checkbox {
@apply hidden;
}
}
}
}
&:has(input.more-checkbox:checked) {
li.event-wrapper--overflow {
@apply flex;
}
li.event-wrapper--more {
@apply hidden;
}
}
}
/* time column */
ol.time {
@apply grid z-0 pt-4;
grid-template-rows: repeat(96, var(--row-height));
grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height));
time {
@apply relative flex items-center justify-end items-start pr-4;
@ -149,8 +291,8 @@
/* event positioning */
ol.events {
@apply grid pt-4;
grid-template-rows: repeat(96, var(--row-height));
@apply grid py-4;
grid-template-rows: repeat(var(--grid-rows, 96), var(--row-height));
--event-col: 0;
--event-row: 0;
--event-end: 4;
@ -158,47 +300,62 @@
--event-fg: var(--color-primary);
li.event {
@apply flex rounded-md relative;
@apply flex rounded-md relative border border-white overflow-hidden;
background-color: var(--event-bg);
color: var(--event-fg);
grid-row-start: var(--event-row);
grid-row-end: var(--event-end);
grid-column-start: var(--event-col);
grid-column-end: calc(var(--event-col) + 1);
width: calc(100% - var(--event-overlap-offset, 100%));
margin-left: var(--event-overlap-offset, 0%);
z-index: var(--event-z, 1);
top: 0.6rem;
transition: translate 100ms ease-in;
> a {
@apply flex flex-col grow px-3 py-2 gap-2px;
a.event {
@apply flex flex-col grow px-3 py-2 gap-2px text-sm;
> span {
@apply font-semibold leading-none break-all;
}
> time {
@apply text-sm;
@apply text-2xs font-medium whitespace-nowrap;
}
}
&:hover {
@apply -translate-y-2px;
/*animation: event-hover 125ms ease forwards;*/
transform: translateY(-2px);
transition:
transform 125ms ease-in;
/*width: 100%;*/
/*z-index: 20; /* enough to make sure it's always on top of other events */
}
}
}
/* bottom controls */
footer {
@apply bg-white flex items-end justify-end col-span-2 border-t-md border-primary z-10;
@apply sticky bottom-0 pb-8;
@apply bg-white flex items-center justify-between col-span-2 border-t-md border-primary;
@apply sticky bottom-0 pt-2 pb-6 z-10;
a.timezone {
@apply text-xs bg-gray-100 rounded px-2 py-1;
}
div.right {
@apply flex items-center gap-4 justify-end;
}
}
/* now indicator */
.now-indicator {
@apply relative pointer-events-none z-2 border-t-3 border-red-600 opacity-90 -ml-2;
@apply relative pointer-events-none z-10 border-t-3 border-red-600 opacity-90 -ml-2;
grid-row: var(--now-row);
grid-column: var(--now-col-start) / var(--now-col-end);
width: calc(100% + 1rem);
top: 0.6rem;
top: calc(0.6rem + (var(--row-height) * var(--now-offset, 0)));
&::before {
@apply block w-3 h-3 rounded-full bg-red-600 -translate-y-1/2 -mt-[1.5px];
@ -208,19 +365,42 @@
}
/* step handling */
.calendar.time[data-density="30"] {
.calendar.time[data-density="30"] { /* half-hourly */
--row-height: 2rem;
ol.time li:nth-child(2n) {
visibility: hidden; /* preserves space + row alignment */
}
}
.calendar.time[data-density="60"] {
.calendar.time[data-density="60"] { /* hourly */
--row-height: 1.25rem;
ol.time li:not(:nth-child(4n + 1)) {
visibility: hidden; /* preserves space + row alignment */
}
&.week {
ol.events {
a.event {
@apply p-2;
}
li.event[data-span="1"] {
a.event > span,
a.event > time {
@apply text-xs;
}
}
li.event[data-span="1"],
li.event[data-span="2"] {
a.event {
@apply flex-col gap-0 py-1;
> span {
@apply min-h-4 line-clamp-1;
}
}
}
}
}
}
/**
@ -243,12 +423,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 */
@ -266,7 +451,7 @@
background-repeat: no-repeat;
}
&[data-weekstart="0"] {
&[data-weekstart="1"] {
ol.events {
background-image:
linear-gradient(
@ -291,6 +476,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);
@ -312,7 +502,7 @@
/**
* calendar list in the left bar
**/
*/
#calendar-toggles {
@apply pb-6;
@ -339,7 +529,7 @@
/* hide the edit link by default */
.edit-link {
@apply hidden absolute pl-4 right-0 top-1/2 -translate-y-1/2 underline text-sm;
@apply hidden absolute pl-4 right-1 top-1/2 -translate-y-1/2 underline text-sm;
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 33%);
}
@ -357,9 +547,52 @@
}
}
/**
* media queries
*/
@media (width >= theme(--breakpoint-2xl)) /* 96rem */
{
.calendar.time {
hgroup {
@apply top-22;
}
ol.day {
@apply top-42;
}
}
}
@media (height <= 50rem)
{
.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;
.name {
order: 2;
}
&:not(:last-of-type)::after {
@apply top-16;
}
}
}
}
}
/**
* animations
**/
*/
@keyframes event-slide {
from {
opacity: 0;
@ -370,7 +603,16 @@
transform: translateX(0);
}
}
@keyframes event-hover {
from {
transform: translateY(0);
z-index: 1;
}
to {
transform: translateY(-2px);
z-index: 10;
}
}
@keyframes header-slide {
from {
opacity: 0;
@ -381,4 +623,3 @@
transform: translateX(0);
}
}

View File

@ -0,0 +1,20 @@
.event-map {
@apply relative -translate-y-20 -mb-12 -ml-6 w-full bg-cover;
aspect-ratio: 2 / 1;
background-image: var(--event-map);
width: calc(100% + 3rem);
}
/* event form fields */
#event-form {
@apply flex flex-col gap-4 pt-6;
.event-field {
display: grid;
grid-template-columns: 3rem auto;
.event-field-icon {
@apply pt-2;
}
}
}

View File

@ -1,9 +1,12 @@
/**
* default text inputs
*/
input[type="date"],
input[type="datetime-local"],
input[type="email"],
input[type="password"],
input[type="text"],
input[type="time"],
input[type="url"],
input[type="search"],
select,
@ -16,6 +19,10 @@ textarea {
&[disabled] {
@apply opacity-50 cursor-not-allowed;
}
&.input--lg {
@apply h-13 text-lg rounded-lg;
}
}
/**
@ -99,10 +106,6 @@ form {
&.modal {
@apply mt-0;
.input-row {
@apply !mt-0;
}
}
}
}
@ -175,6 +178,13 @@ article.settings {
h3 + .input-row {
@apply mt-6;
}
.input-rows {
@apply flex flex-col gap-3;
.input-row + .input-row {
@apply mt-0;
}
}
@container style(--can-scroll: 1) {
.input-row--actions {
@apply !border-black;

View File

@ -1,66 +1,226 @@
.close-modal {
@apply hidden;
}
/**
* modal uses a <dialog> as the backdrop and grid, with #modal and #modal-aside on top
*/
dialog {
@apply grid fixed top-0 right-0 bottom-0 left-0 m-0 p-0 pointer-events-none;
@apply justify-items-center items-start bg-transparent opacity-0 invisible;
@apply w-full h-full max-w-full max-h-full overflow-y-hidden;
@apply grid fixed inset-0 m-0 p-0 pointer-events-none;
@apply place-items-center bg-transparent opacity-0 invisible;
@apply w-full h-full max-w-none max-h-none overflow-clip;
background-color: rgba(26, 26, 26, 0.75);
backdrop-filter: blur(0.25rem);
grid-template-rows: minmax(20dvh, 2rem) 1fr;
overscroll-behavior: contain;
scrollbar-gutter: auto;
transition:
background-color 150ms cubic-bezier(0,0,.2,1),
opacity 150ms cubic-bezier(0,0,.2,1),
visibility 150ms cubic-bezier(0,0,.2,1);
z-index: 100;
/* primary modal container */
#modal {
@apply relative rounded-lg bg-white border-gray-200 p-0;
@apply flex flex-col items-start col-start-1 row-start-2 translate-y-4;
@apply overscroll-contain overflow-y-auto;
max-height: calc(100vh - 5em);
@apply relative rounded-xl bg-white border-gray-200 p-0;
@apply flex flex-col items-start col-start-1 translate-y-4 overflow-hidden;
max-height: calc(100dvh - 5rem);
width: 91.666667%;
max-width: 36rem;
transition: all 150ms cubic-bezier(0,0,.2,1);
box-shadow: #00000040 0 1.5rem 4rem -0.5rem;
transition: translate 150ms ease-in-out;
box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
/* close button */
> .close-modal {
@apply block absolute top-4 right-4;
@apply block absolute top-4 right-4 z-3;
}
> .content {
@apply w-full;
/* content wrapper and content defaults */
> .modal-content {
@apply grid w-full h-full overflow-hidden;
/* set the grid based on which elements the content section has */
grid-template-rows: 1fr;
&:has(header):has(footer) {
grid-template-rows: auto minmax(0, 1fr) auto;
}
&:has(header):not(:has(footer)) {
grid-template-rows: auto minmax(0, 1fr);
}
&:has(footer):not(:has(header)) {
grid-template-rows: minmax(0, 1fr) auto;
}
/* modal header */
header {
@apply px-6 py-6;
h2 {
@apply pr-12;
}
@apply sticky top-0 bg-white flex items-center px-6 min-h-20 h-20 z-2 pr-16;
}
/* main content pane */
section {
@apply flex flex-col px-6 pb-8;
/* main content wrapper */
section.modal-body {
@apply flex flex-col px-6 pb-8 overflow-y-auto;
&.no-margin {
@apply p-0;
}
/* overlay gradient to signal scrollability */
&::after {
@apply h-8 w-full absolute bottom-20 left-0 bg-scrollbar;
content: '';
}
}
/* standard form with 1rem gap between rows */
form {
@apply flex flex-col gap-4;
/* paneled modals get different behavior */
&.settings:has(.tab-panels) {
@apply flex-1 min-h-0 gap-0;
}
}
/* footer */
footer {
@apply px-6 py-4 border-t-md border-gray-400 flex justify-between;
@apply sticky bottom-0 bg-white px-6 h-20 border-t-md border-gray-300 flex items-center justify-between;
}
/* event modal with a map */
&.with-map {
header {
background: linear-gradient(180deg,rgba(255, 255, 255, 0.67) 0%, rgba(255, 255, 255, 0) 100%);
}
}
}
/* wider version */
&.modal--wide {
max-width: 48rem;
}
/* forced height on variable content modals */
&.modal--square {
block-size: clamp(32rem, 72dvh, 54rem);
max-block-size: calc(100dvh - 5rem);
}
/* event form modal with a natural language parser and enhanced interactions */
&.modal--event
{
header.input {
@apply border-b border-gray-400;
input {
@apply w-full -ml-2 border-0 rounded-sm shadow-none;
}
/* if there are panels, move it down */
+ section.modal-body .tabs,
+ section.modal-body .panels {
@apply pt-4;
}
}
}
/* when the event-create natural language parser is collapsed */
&.natural-collapsed {
@apply rounded-full;
block-size: auto;
header.input {
@apply border-none;
input {
@apply rounded-full;
}
}
> .modal-content {
grid-template-rows: auto;
}
> .modal-content > section.modal-body,
> .modal-content > footer {
@apply hidden;
}
}
}
/* extra container over the backdrop below the modal */
#modal-aside {
@apply justify-self-center pl-4 h-0 flex flex-row justify-between items-start gap-2;
@apply translate-y-4 opacity-0 invisible;
width: 91.666667%;
max-width: 36rem;
pointer-events: none;
transition:
translate 250ms ease-in-out,
opacity 250ms ease-in-out,
visibility 250ms ease-in-out;
&.is-visible {
@apply opacity-100 visible h-auto;
pointer-events: auto;
}
.modal-aside-list {
@apply list-none m-0 pt-3 flex flex-col justify-start items-start gap-2;
li {
@apply text-white rounded-full pl-9 pr-3 py-1 text-sm;
background-color: rgba(0, 0, 0, 0.25);
background-position: 0.75rem 50%;
background-repeat: no-repeat;
background-size: 1rem;
&.is-visible {
animation: modal-aside-list 250ms ease-in-out forwards;
}
&[data-aside-key="title"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
}
&[data-aside-key="date"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M16 2v4'/%3E%3Crect width='18' height='18' x='3' y='4' rx='2'/%3E%3Cpath d='M3 10h18'/%3E%3C/svg%3E");
}
&[data-aside-key="location"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0'/%3E%3Ccircle cx='12' cy='10' r='3'/%3E%3C/svg%3E");
}
&[data-aside-key="repeat"] {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m17 2 4 4-4 4'/%3E%3Cpath d='M3 11v-1a4 4 0 0 1 4-4h14'/%3E%3Cpath d='m7 22-4-4 4-4'/%3E%3Cpath d='M21 13v1a4 4 0 0 1-4 4H3'/%3E%3C/svg%3E");
}
strong {
@apply hidden;
}
}
}
.modal-aside-expand {
@apply whitespace-nowrap bg-transparent border-none mt-1 text-sm underline;
@apply text-white/90 hover:text-white cursor-pointer;
}
}
/* reposition #modal and #aside when the #aside is used */
&:has(#modal-aside) {
grid-template-rows: 1fr 0;
}
&:has(#modal-aside.is-visible) {
grid-template-rows: 40dvh auto;
#modal {
@apply self-end;
}
#modal-aside {
@apply self-start;
}
}
/* hide backdrop before the modal is open */
&::backdrop {
display: none;
}
/* open interactions */
&[open] {
@apply opacity-100 visible;
pointer-events: inherit;
@ -69,8 +229,57 @@ dialog {
@apply translate-y-0;
}
#modal-aside.is-visible {
@apply translate-y-0;
}
&::backdrop {
@apply opacity-100;
}
}
}
/**
* tabbed content panels in a modal
*/
.tab-panels {
@apply grid items-start min-h-0 h-full gap-4;
grid-template-columns: 12rem minmax(0, 1fr);
.tabs {
@apply sticky top-0 self-start;
}
.tabs--vertical {
@apply pl-4;
li {
@apply rounded-r-none;
&[aria-selected="true"] {
button {
@apply bg-cyan-200;
}
}
}
}
.panels {
@apply h-full min-h-0 pt-2 pr-6 pb-6 pl-1 overflow-y-auto;
}
}
/**
* animations
*/
@keyframes modal-aside-list
{
from {
opacity: 0;
transform: translateX(-1rem);
}
to {
opacity: 100;
transform: translateX(0);
}
}

View File

@ -0,0 +1,17 @@
.tabs {
@apply flex flex-row gap-0 items-center justify-start p-2 gap-1;
&.tabs--vertical {
@apply flex-col items-start;
}
li {
@apply flex flex-col w-full rounded-md;
button {
&:hover {
@apply bg-cyan-100;
}
}
}
}

View File

@ -1,5 +1,19 @@
import './bootstrap';
import htmx from 'htmx.org';
import { handleEventModalAfterSwap, initEventModalGlobals, initEventModalUI } from './modules/event-modal';
const SELECTORS = {
calendarToggle: '.calendar-toggle',
calendarExpandToggle: '[data-calendar-expand]',
colorPicker: '[data-colorpicker]',
colorPickerColor: '[data-colorpicker-color]',
colorPickerHex: '[data-colorpicker-hex]',
colorPickerRandom: '[data-colorpicker-random]',
monthDay: '.calendar.month .day',
monthDayEvent: 'a.event',
monthDayMore: '[data-day-more]',
monthDayMoreWrap: '.more-events',
};
/**
* htmx/global
@ -18,41 +32,126 @@ document.addEventListener('htmx:configRequest', (evt) => {
if (token) evt.detail.headers['X-CSRF-TOKEN'] = token
})
/**
* calendar toggle
* progressive enhancement on html form with no js
*
* global auth expiry redirect (fetch/axios)
*/
document.addEventListener('change', event => {
const checkbox = event.target;
// ignore anything that isnt one of our checkboxes
if (!checkbox.matches('.calendar-toggle')) return;
const AUTH_REDIRECT_STATUSES = new Set([401, 419]);
const redirectToLogin = () => {
if (window.location.pathname !== '/login') {
window.location.assign('/login');
}
};
const slug = checkbox.value;
const show = checkbox.checked;
if (window.fetch) {
const originalFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const response = await originalFetch(...args);
if (response && AUTH_REDIRECT_STATUSES.has(response.status)) {
redirectToLogin();
}
return response;
};
}
// toggle .hidden on every matching event element
document
.querySelectorAll(`[data-calendar="${slug}"]`)
.forEach(el => el.classList.toggle('hidden', !show));
});
/**
* calendar view picker
* progressive enhancement on html form with no js
*/
document.addEventListener('change', (e) => {
const form = e.target?.form;
if (!form || form.id !== 'calendar-view') return;
if (e.target.name !== 'view') return;
form.requestSubmit();
if (window.axios) {
window.axios.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status;
if (AUTH_REDIRECT_STATUSES.has(status)) {
redirectToLogin();
}
return Promise.reject(error);
}
);
}
/**
*
* calendar ui improvements
*/
// progressive enhancement on html form with no JS
document.addEventListener('change', (event) => {
const target = event.target;
if (target?.matches(SELECTORS.calendarToggle)) {
const slug = target.value;
const show = target.checked;
document
.querySelectorAll(`[data-calendar="${slug}"]`)
.forEach(el => el.classList.toggle('hidden', !show));
return;
}
const form = target?.form;
if (!form || form.id !== 'calendar-view') return;
if (target.name !== 'view') return;
form.requestSubmit();
});
/**
*
* 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 (8:00am)
const target = calendar.querySelector('[data-slot-minutes="480"]');
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;
event.preventDefault();
const main = toggle.closest('main');
if (!main) return;
const isExpanded = main.classList.toggle('expanded');
toggle.setAttribute('aria-pressed', isExpanded ? 'true' : 'false');
});
/**
*
* 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());
@ -71,9 +170,9 @@ function initColorPickers(root = document) {
if (el.__colorpickerWired) return;
el.__colorpickerWired = true;
const color = el.querySelector('[data-colorpicker-color]');
const hex = el.querySelector('[data-colorpicker-hex]');
const btn = el.querySelector('[data-colorpicker-random]');
const color = el.querySelector(SELECTORS.colorPickerColor);
const hex = el.querySelector(SELECTORS.colorPickerHex);
const btn = el.querySelector(SELECTORS.colorPickerRandom);
if (!color || !hex) return;
@ -137,13 +236,172 @@ function initColorPickers(root = document) {
}
};
root.querySelectorAll('[data-colorpicker]').forEach(wire);
root.querySelectorAll(SELECTORS.colorPicker).forEach(wire);
}
/**
*
* month view overflow handling (progressive enhancement)
*/
function initMonthOverflow(root = document) {
const days = root.querySelectorAll(SELECTORS.monthDay);
days.forEach((day) => updateMonthOverflow(day));
}
function ensureDayMoreButton(dayEl) {
let wrapper = dayEl.querySelector(SELECTORS.monthDayMoreWrap);
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'more-events';
dayEl.appendChild(wrapper);
}
let button = wrapper.querySelector(SELECTORS.monthDayMore);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.className = 'day-more hidden';
button.setAttribute('data-day-more', '');
wrapper.appendChild(button);
}
return button;
}
function formatMoreLabel(dayEl, count) {
const template = dayEl.getAttribute('data-more-label') || ':count more';
return template.replace(':count', count);
}
function lessLabel(dayEl) {
return dayEl.getAttribute('data-less-label') || 'Show less';
}
function updateMonthOverflow(dayEl) {
if (!dayEl) return;
const events = Array.from(dayEl.querySelectorAll(SELECTORS.monthDayEvent))
.filter((el) => !el.classList.contains('hidden'));
const moreButton = ensureDayMoreButton(dayEl);
if (!events.length) {
moreButton.textContent = '';
moreButton.classList.add('hidden');
moreButton.removeAttribute('aria-expanded');
dayEl.classList.remove('day--event-overflow');
dayEl.setAttribute('data-event-visible', '0');
return;
}
if (dayEl.classList.contains('is-expanded')) {
moreButton.textContent = lessLabel(dayEl);
moreButton.classList.remove('hidden');
moreButton.setAttribute('aria-expanded', 'true');
dayEl.classList.remove('day--event-overflow');
dayEl.setAttribute('data-event-visible', String(events.length));
return;
}
const wrapper = moreButton.closest(SELECTORS.monthDayMoreWrap);
let wrapperHeight = wrapper ? wrapper.getBoundingClientRect().height : 0;
if (wrapperHeight === 0 && wrapper) {
const wasHidden = moreButton.classList.contains('hidden');
const prevVisibility = moreButton.style.visibility;
if (wasHidden) {
moreButton.classList.remove('hidden');
moreButton.style.visibility = 'hidden';
}
wrapperHeight = wrapper.getBoundingClientRect().height || 0;
if (wasHidden) {
moreButton.classList.add('hidden');
moreButton.style.visibility = prevVisibility;
}
}
const prevVisibility = dayEl.style.visibility;
dayEl.style.visibility = 'hidden';
dayEl.removeAttribute('data-event-visible');
dayEl.classList.remove('day--event-overflow');
const availableHeight = dayEl.clientHeight - wrapperHeight;
let hiddenCount = 0;
events.forEach((eventEl) => {
const bottom = eventEl.offsetTop + eventEl.offsetHeight;
if (bottom > availableHeight + 0.5) {
hiddenCount += 1;
}
});
dayEl.style.visibility = prevVisibility;
const visibleCount = Math.max(0, events.length - hiddenCount);
dayEl.setAttribute('data-event-visible', String(visibleCount));
if (hiddenCount > 0) {
moreButton.textContent = formatMoreLabel(dayEl, hiddenCount);
moreButton.classList.remove('hidden');
moreButton.setAttribute('aria-expanded', 'false');
dayEl.classList.add('day--event-overflow');
} else {
moreButton.textContent = '';
moreButton.classList.add('hidden');
moreButton.removeAttribute('aria-expanded');
dayEl.classList.remove('day--event-overflow');
}
}
// 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;
const dayEl = button.closest(SELECTORS.monthDay);
if (!dayEl) return;
dayEl.classList.toggle('is-expanded');
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() {
initEventModalGlobals();
initEventModalUI();
initColorPickers();
initTimeViewAutoScroll();
initMonthOverflow();
}
// initial bind
document.addEventListener('DOMContentLoaded', () => initColorPickers());
document.addEventListener('DOMContentLoaded', initUI);
// rebind in htmx for swapped content
document.addEventListener('htmx:afterSwap', (e) => {
initColorPickers(e.target);
const target = e.detail?.target || e.target;
handleEventModalAfterSwap(target);
initEventModalUI(target);
initColorPickers(target);
initTimeViewAutoScroll(target);
initMonthOverflow(target);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 14v2.2l1.6 1"/><path d="M16 2v4"/><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M3 10h5"/><path d="M8 2v4"/><circle cx="16" cy="16" r="6"/></svg>

After

Width:  |  Height:  |  Size: 375 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-icon lucide-calendar"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>

Before

Width:  |  Height:  |  Size: 345 B

After

Width:  |  Height:  |  Size: 294 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-to-line-icon lucide-arrow-left-to-line"><path d="M3 19V5"/><path d="m13 6-6 6 6 6"/><path d="M7 12h14"/></svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></svg>

After

Width:  |  Height:  |  Size: 247 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-to-line-icon lucide-arrow-right-to-line"><path d="M17 12H3"/><path d="m11 18 6-6-6-6"/><path d="M21 5v14"/></svg>

After

Width:  |  Height:  |  Size: 327 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@ -1,38 +0,0 @@
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Profile') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.addresses-form', [
'home' => $home ?? null,
'billing' => $billing ?? null,
])
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
<div class="max-w-xl">
@include('account.partials.delete-user-form')
</div>
</div>
</div>
</div>
</x-app-layout>

View File

@ -8,9 +8,9 @@
<!-- Password -->
<div>
<x-input-label for="password" :value="__('Password')" />
<x-input.label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
<x-input.text id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
@ -19,9 +19,9 @@
</div>
<div class="flex justify-end mt-4">
<x-primary-button>
<x-button variant="primary">
{{ __('Confirm') }}
</x-primary-button>
</x-button>
</div>
</form>
</x-guest-layout>

View File

@ -11,15 +11,15 @@
<!-- Email Address -->
<div>
<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 />
<x-input.label for="email" :value="__('Email')" />
<x-input.text id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
<x-input.error :messages="$errors->get('email')" class="mt-2" />
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
<x-button variant="primary">
{{ __('Email Password Reset Link') }}
</x-primary-button>
</x-button>
</div>
</form>
</x-guest-layout>

View File

@ -2,34 +2,47 @@
<!-- 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>
<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.error :messages="$errors->get('email')" class="mt-2" />
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="email" :value="__('Email')" />
<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">
<x-input.label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="current-password" />
<x-input.error :messages="$errors->get('password')" class="mt-2" />
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="password" :value="__('Password')" />
<x-input.text
id="password"
type="password"
name="password"
required
autocomplete="current-password" />
<x-input.error :messages="$errors->get('password')" class="mt-2" />
</div>
</div>
<!-- Remember Me -->
<div class="block mt-4">
<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 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">

View File

@ -4,23 +4,23 @@
<!-- Name -->
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
<x-input.label for="name" :value="__('Name')" />
<x-input.text id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
<x-input.error :messages="$errors->get('name')" class="mt-2" />
</div>
<!-- Email Address -->
<div class="mt-4">
<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 autocomplete="username" />
<x-input.label for="email" :value="__('Email')" />
<x-input.text id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
<x-input.error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-input.label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full"
<x-input.text id="password" class="block mt-1 w-full"
type="password"
name="password"
required autocomplete="new-password" />
@ -30,9 +30,9 @@
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-input.label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="password_confirmation" class="block mt-1 w-full"
<x-input.text id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
@ -44,9 +44,9 @@
{{ __('Already registered?') }}
</a>
<x-primary-button class="ms-4">
<x-button variant="primary" class="ms-4">
{{ __('Register') }}
</x-primary-button>
</x-button>
</div>
</form>
</x-guest-layout>

View File

@ -7,23 +7,23 @@
<!-- Email Address -->
<div>
<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', $request->email)" required autofocus autocomplete="username" />
<x-input.label for="email" :value="__('Email')" />
<x-input.text id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
<x-input.error :messages="$errors->get('email')" class="mt-2" />
</div>
<!-- Password -->
<div class="mt-4">
<x-input-label for="password" :value="__('Password')" />
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-input.label for="password" :value="__('Password')" />
<x-input.text id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
<x-input.error :messages="$errors->get('password')" class="mt-2" />
</div>
<!-- Confirm Password -->
<div class="mt-4">
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
<x-input.label for="password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="password_confirmation" class="block mt-1 w-full"
<x-input.text id="password_confirmation" class="block mt-1 w-full"
type="password"
name="password_confirmation" required autocomplete="new-password" />
@ -31,9 +31,9 @@
</div>
<div class="flex items-center justify-end mt-4">
<x-primary-button>
<x-button variant="primary">
{{ __('Reset Password') }}
</x-primary-button>
</x-button>
</div>
</form>
</x-guest-layout>

View File

@ -14,9 +14,9 @@
@csrf
<div>
<x-primary-button>
<x-button variant="primary">
{{ __('Resend Verification Email') }}
</x-primary-button>
</x-button>
</div>
</form>

View File

@ -8,15 +8,15 @@
{{-- Name --}}
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
<x-input.label for="name" :value="__('Name')" />
<x-input.text id="name" name="name" type="text" class="mt-1 block w-full"
:value="old('name', $instance?->displayname ?? '')" required autofocus />
<x-input.error class="mt-2" :messages="$errors->get('name')" />
</div>
{{-- Description --}}
<div>
<x-input-label for="description" :value="__('Description')" />
<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', $instance?->description ?? '') }}</textarea>
<x-input.error class="mt-2" :messages="$errors->get('description')" />
@ -24,7 +24,7 @@
{{-- Timezone --}}
<div>
<x-input-label for="timezone" :value="__('Timezone')" />
<x-input.label for="timezone" :value="__('Timezone')" />
<select id="timezone" name="timezone"
class="mt-1 block w-full rounded-md border-gray-300 focus:border-indigo-300 focus:ring-3">
@foreach(timezone_identifiers_list() as $tz)
@ -38,15 +38,15 @@
</div>
{{-- COLOR --}}
<x-input-label for="color" :value="__('Color')" class="mt-4" />
<x-text-input id="color" name="color" type="text" class="mt-1 block w-32"
<x-input.label for="color" :value="__('Color')" class="mt-4" />
<x-input.text id="color" name="color" type="text" class="mt-1 block w-32"
placeholder="#007AFF"
:value="old('color', $calendar?->meta->color ?? $instance?->calendarcolor ?? '')" />
{{-- Submit --}}
<div class="flex justify-end">
<x-primary-button>
<x-button variant="primary">
{{ $isEdit ? __('Save Changes') : __('Create') }}
</x-primary-button>
</x-button>
</div>
</div>

View File

@ -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">
@ -74,6 +79,7 @@
:view="$view"
:density="$density"
:headers="$mini_headers"
:daytime_hours="$daytime_hours"
class="aside-inset"
/>
</x-slot>
@ -84,13 +90,16 @@
@if(!empty($header['span']))
<span>{{ $header['span'] }}</span>
@endif
<button
type="button"
class="button button--icon button--sm calendar-expand-toggle"
data-calendar-expand
aria-label="{{ __('calendar.toggle_sidebar') }}"
>
<x-icon-collapse />
</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') }}"
@ -105,12 +114,14 @@
{{-- persist values from other forms --}}
<input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="density" value="{{ $density['step'] }}">
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
<nav class="button-group button-group--primary">
<x-button.group-button
type="submit"
name="date"
value="{{ $nav['prev'] }}"
class="button--icon"
aria-label="Go back 1 month">
<x-icon-chevron-left />
</x-button.group-button>
@ -125,6 +136,7 @@
type="submit"
name="date"
value="{{ $nav['next'] }}"
class="button--icon"
aria-label="Go forward 1 month">
<x-icon-chevron-right />
</x-button.group-button>
@ -148,16 +160,41 @@
{{-- persist data from density form --}}
<input type="hidden" name="density" value="{{ $density['step'] }}">
<x-button.group-input value="day" :active="$view === 'day'">Day</x-button.group-input>
<x-button.group-input value="week" :active="$view === 'week'">Week</x-button.group-input>
<x-button.group-input value="month" :active="$view === 'month'">Month</x-button.group-input>
<x-button.group-input value="four" :active="$view === 'four'">3-Up</x-button.group-input>
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
<x-button.group-input value="day" :active="$view === 'day'">
<span class="lg:hidden">1</span>
<span class="hidden lg:inline">Day</span>
</x-button.group-input>
<x-button.group-input value="week" :active="$view === 'week'">
<span class="lg:hidden">7</span>
<span class="hidden lg:inline">Week</span>
</x-button.group-input>
<x-button.group-input value="month" :active="$view === 'month'">
<span class="lg:hidden">31</span>
<span class="hidden lg:inline">Month</span>
</x-button.group-input>
<x-button.group-input value="four" :active="$view === 'four'">
<span class="lg:hidden">4</span>
<span class="hidden lg:inline">4-day</span>
</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>
@ -169,13 +206,17 @@
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"
:active="$active"
:density="$density"
:weekstart="$week_start"
:daytime_hours="$daytime_hours"
:timezone="$timezone"
:now="$now"
/>
@break
@ -184,12 +225,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
@ -198,12 +243,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

View File

@ -10,6 +10,8 @@
$meta = $data['meta'] ?? null;
$isRemote = (bool) ($meta?->is_remote ?? false);
$isShared = (bool) ($meta?->is_shared ?? false);
$shareUrl = $data['shareUrl'] ?? null;
$color = old('color', $instance->resolvedColor());
@ -75,6 +77,37 @@
</div>
</div>
@if (!$isRemote)
<div class="input-row input-row--1">
<div class="input-cell">
<input type="hidden" name="is_shared" value="0">
<x-input.checkbox-label
name="is_shared"
value="1"
:checked="old('is_shared', $isShared)"
:label="__('calendar.ics.share')"
/>
<p class="text-sm text-gray-500 mt-1">{{ __('calendar.ics.share_help') }}</p>
</div>
</div>
@if ($shareUrl)
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.text-label
:label="__('calendar.ics.public_url')"
id="ics_public_url"
name="ics_public_url"
type="url"
:value="$shareUrl"
disabled="true"
:description="__('calendar.ics.public_url_help')"
/>
</div>
</div>
@endif
@endif
@if ($isRemote)
<div class="input-row input-row--1">
<div class="input-cell">

View File

@ -2,6 +2,8 @@
'grid' => [],
'calendars' => [],
'events' => [],
'all_day_events' => [],
'has_all_day' => false,
'class' => '',
'slots' => [],
'timeformat' => '',
@ -9,11 +11,21 @@
'active' => [],
'density' => '30',
'now' => [],
'daytime_hours' => [],
'timezone' => 'UTC',
])
<section
class="calendar {{ $class }}" data-density="{{ $density['step'] }}"
style="--now-row: {{ $now['row'] }}; --now-col-start: {{ $now['col_start'] }}; --now-col-end: {{ $now['col_end'] }};"
@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)
@ -23,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>
@ -39,6 +58,12 @@
@endif
</ol>
<footer>
<x-calendar.time.density view="day" :density="$density" />
<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>

View File

@ -2,6 +2,8 @@
'grid' => [],
'calendars' => [],
'events' => [],
'all_day_events' => [],
'has_all_day' => false,
'class' => '',
'slots' => [],
'timeformat' => '',
@ -9,27 +11,58 @@
'active' => [],
'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'] }};"
--now-col-end: {{ (int) $now['col_end'] }};
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
"
>
<hgroup>
@foreach ($hgroup as $h)
@php
$dayParams = [
'view' => 'day',
'date' => $h['date'],
'density' => $density['step'],
'daytime_hours' => (int) ($daytime_hours['enabled'] ?? 0),
];
@endphp
<div data-date="{{ $h['date'] }}" @class(['day-header', 'active' => $h['is_today'] ?? false])>
<span class="name">{{ $h['dow'] }}</span>
<a class="number" href="#">{{ $h['day'] }}</a>
<a class="number"
href="{{ route('calendar.index', $dayParams) }}"
hx-get="{{ route('calendar.index', $dayParams) }}"
hx-target="#calendar"
hx-select="#calendar"
hx-swap="outerHTML"
hx-push-url="true"
hx-include="#calendar-toggles">
{{ $h['day'] }}
</a>
</div>
@endforeach
@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>
@ -42,6 +75,12 @@
@endif
</ol>
<footer>
<x-calendar.time.density view="four" :density="$density" />
<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>

View File

@ -5,6 +5,7 @@
'class' => '',
'density' => [],
'headers' => [],
'daytime_hours' => [],
])
<section id="mini" class="mini mini--month {{ $class }}">
@ -22,6 +23,7 @@
{{-- preserve main calendar context for full-reload fallback --}}
<input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="date" value="{{ request('date') }}">
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
{{-- nav buttons --}}
<button type="submit" name="mini" class="button--icon button--sm" value="{{ $nav['prev'] }}" aria-label="Go back 1 month">
<x-icon-chevron-left />
@ -51,6 +53,7 @@
{{-- stay on the same view (month/week/etc --}}
<input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="density" value="{{ $density['step'] }}">
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
@foreach ($mini['days'] as $day)
<button

View File

@ -19,12 +19,16 @@
@php
$event = $events[$eventId];
$color = $event['color'] ?? '#999';
$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', [$event['calendar_slug'], $event['id']]) }}"
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
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'] }}"

View File

@ -0,0 +1,47 @@
@props([
'event' => [],
])
@php
$row = (int) ($event['all_day_row'] ?? 1);
$end = $row + 1;
$isMore = (bool) ($event['all_day_more'] ?? false);
$isOverflow = (bool) ($event['all_day_overflow'] ?? false);
@endphp
<li class="event-wrapper{{ $isMore ? ' event-wrapper--more' : '' }}{{ $isOverflow ? ' event-wrapper--overflow' : '' }}"
data-event-id="{{ $event['occurrence_id'] ?? $event['id'] }}"
data-calendar-id="{{ $event['calendar_slug'] ?? '' }}"
data-more-count="{{ $event['all_day_more_count'] ?? '' }}"
style="
--event-col: {{ $event['start_col'] ?? 1 }};
--event-row: {{ $row }};
--event-end: {{ $end }};
--event-bg: {{ $event['color'] ?? 'var(--color-gray-100)' }};
--event-fg: {{ $event['color_fg'] ?? 'var(--color-primary)' }};">
@if($isMore)
@php
$moreId = 'more-col-'.$event['start_col'];
@endphp
<label class="event event--more" for="{{ $moreId }}" tabindex="0">
<input id="{{ $moreId }}" type="checkbox" class="more-checkbox" />
<span>{{ __('calendar.event.show_more', ['count' => $event['all_day_more_count'] ?? 0]) }}</span>
</label>
@else
@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>
@endif
</li>

View File

@ -0,0 +1,40 @@
@props([
'daytime_hours' => [],
'view' => 'day',
'density' => [],
])
@php
$enabled = (int) ($daytime_hours['enabled'] ?? 0) === 1;
@endphp
<form id="calendar-daytime-hours"
method="get"
class="inline-flex items-center gap-2 text-sm"
action="{{ route('calendar.index') }}"
hx-get="{{ route('calendar.index') }}"
hx-target="#calendar"
hx-select="#calendar"
hx-swap="outerHTML"
hx-push-url="true"
hx-trigger="change"
hx-include="#calendar-toggles">
{{-- preserve current view and anchor date --}}
<input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="date" value="{{ $density['anchor'] ?? request('date') }}">
<input type="hidden" name="density" value="{{ $density['step'] ?? 30 }}">
{{-- unchecked checkboxes don't submit a value --}}
<input type="hidden" name="daytime_hours" value="0">
<x-input.checkbox-label
name="daytime_hours"
value="1"
label="{{ __('Daytime hours') }}"
:checked="$enabled"
/>
<noscript>
<button type="submit" class="button">{{ __('Apply') }}</button>
</noscript>
</form>

View File

@ -1,6 +1,7 @@
@props([
'density' => [],
'view' => 'day',
'daytime_hours' => [],
])
<form id="calendar-density"
@ -18,6 +19,7 @@
{{-- preserve current view and anchor date --}}
<input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="date" value="{{ $density['anchor'] }}">
<input type="hidden" name="daytime_hours" value="{{ (int) ($daytime_hours['enabled'] ?? 0) }}">
<x-button.group-input value="15" name="density" :active="(int)($density['step'] ?? 30) === 15">15m</x-button.group-input>
<x-button.group-input value="30" name="density" :active="(int)($density['step'] ?? 30) === 30">30m</x-button.group-input>

View File

@ -3,24 +3,36 @@
])
<li class="event"
data-event-id="{{ $event['id'] }}"
data-event-id="{{ $event['occurrence_id'] ?? $event['id'] }}"
data-calendar-id="{{ $event['calendar_slug'] }}"
data-start="{{ $event['start_ui'] }}"
data-duration="{{ $event['duration'] }}"
data-span="{{ $event['row_span'] }}"
style="
--event-row: {{ $event['start_row'] }};
--event-end: {{ $event['end_row'] }};
--event-col: {{ $event['start_col'] }};
--event-bg: {{ $event['color'] }};
--event-fg: {{ $event['color_fg'] }};
--event-overlap-count: {{ $event['overlap_count'] ?? 1 }};
--event-overlap-index: {{ $event['overlap_index'] ?? 0 }};
--event-overlap-width: {{ $event['overlap_width'] ?? 100 }}%;
--event-overlap-offset: {{ $event['overlap_offset'] ?? 0 }}%;
--event-z: {{ $event['overlap_z'] ?? 1 }};
">
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
hx-target="#modal"
hx-push-url="false"
hx-swap="innerHTML"
data-calendar="{{ $event['calendar_slug'] }}"
@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>
<time datetime="{{ $event['start'] }}">{{ $event['start_ui'] }} - {{ $event['end_ui'] }}</time>

View File

@ -2,6 +2,8 @@
'grid' => [],
'calendars' => [],
'events' => [],
'all_day_events' => [],
'has_all_day' => false,
'class' => '',
'slots' => [],
'timeformat' => '',
@ -10,27 +12,59 @@
'density' => '30',
'weekstart' => 0,
'now' => [],
'daytime_hours' => [],
'timezone' => 'UTC',
])
<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'] }};"
--now-col-end: {{ (int) $now['col_end'] }};
--grid-rows: {{ $daytime_hours['rows'] ?? 96 }};
"
>
<hgroup>
@foreach ($hgroup as $h)
@php
$dayParams = [
'view' => 'day',
'date' => $h['date'],
'density' => $density['step'],
'daytime_hours' => (int) ($daytime_hours['enabled'] ?? 0),
];
@endphp
<div data-date="{{ $h['date'] }}" @class(['day-header', 'active' => $h['is_today'] ?? false])>
<span class="name">{{ $h['dow'] }}</span>
<a class="number" href="#">{{ $h['day'] }}</a>
<a class="number"
href="{{ route('calendar.index', $dayParams) }}"
hx-get="{{ route('calendar.index', $dayParams) }}"
hx-target="#calendar"
hx-select="#calendar"
hx-swap="outerHTML"
hx-push-url="true"
hx-include="#calendar-toggles">
{{ $h['day'] }}
</a>
</div>
@endforeach
@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>
@ -43,6 +77,12 @@
@endif
</ol>
<footer>
<x-calendar.time.density view="week" :density="$density" />
<div class="left">
<a href="{{ route('account.locale') }}" class="timezone">{{ $timezone }}</a>
</div>
<div class="right">
<x-calendar.time.daytime-hours view="week" :density="$density" :daytime_hours="$daytime_hours" />
<x-calendar.time.density view="week" :density="$density" :daytime_hours="$daytime_hours" />
</div>
</footer>
</section>

View File

@ -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>

View File

@ -1,5 +0,0 @@
@props(['value'])
<label {{ $attributes->merge(['class' => 'block font-medium text-sm text-gray-700']) }}>
{{ $value ?? $slot }}
</label>

View File

@ -13,7 +13,7 @@
name="{{ $name }}"
value="{{ $value }}"
placeholder="{{ $placeholder }}"
{{ $attributes->merge(['class' => 'text']) }}
{{ $attributes->merge(['class' => 'text '.$class]) }}
@if($style !== '') style="{{ $style }}" @endif
@required($required)
@disabled($disabled) />

View File

@ -1,3 +1,3 @@
<section class="flex flex-col px-8 pb-6">
<section {{ $attributes->class(['modal-body']) }}>
{{ $slot }}
</section>

View File

@ -1,8 +1,17 @@
@props([
'modalClass' => null,
])
<form method="dialog" class="close-modal">
<x-button.icon type="submit" label="Close the modal" autofocus>
<x-icon-x />
</x-button.icon>
</form>
<div class="content">
<div
@if(filled($modalClass))
data-modal-class="{{ trim((string) $modalClass) }}"
@endif
{{ $attributes->class('modal-content') }}
>
{{ $slot }}
</div>

View File

@ -1,11 +1,12 @@
<dialog
hx-on:click="if(event.target === this) this.close()"
hx-on:close="document.getElementById('modal').innerHTML=''"
>
<dialog>
<div id="modal"
hx-target="this"
hx-on::after-swap="this.closest('dialog')?.showModal()"
hx-swap="innerHTML">
</div>
<div id="modal-aside" aria-hidden="true">
<ul class="modal-aside-list" data-modal-aside-list></ul>
<button type="button" class="modal-aside-expand" data-modal-expand>
Edit details
</button>
</div>
</dialog>

View File

@ -2,8 +2,6 @@
'border' => false,
])
<header @class(['header--with-border' => $border])>
<h2>
{{ $slot }}
</h2>
<header {{ $attributes->class(['header--with-border' => $border]) }}>
{{ $slot }}
</header>

View File

@ -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>

View File

@ -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>

View File

@ -1,3 +0,0 @@
@props(['disabled' => false])
<input @disabled($disabled) {{ $attributes->merge(['class' => '']) }}>

View File

@ -2,7 +2,7 @@
<x-slot name="header">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold leading-tight">
{{ $event->exists ? __('Edit Event') : __('Create Event') }}
{{ $event->exists ? __('Edit event details') : __('Create a new event') }}
</h2>
{{-- “Back” breadcrumb --}}
@ -16,108 +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>
{{-- 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>

View File

@ -0,0 +1,29 @@
@if (empty($suggestions))
<div></div>
@else
<ul class="mt-2 rounded-md border border-gray-200 shadow-sm bg-white divide-y divide-gray-100">
@foreach ($suggestions as $suggestion)
<li>
<button
type="button"
class="w-full text-left px-3 py-2 hover:bg-gray-50"
data-attendee-pick
data-attendee-email="{{ $suggestion['email'] ?? '' }}"
data-attendee-name="{{ $suggestion['name'] ?? '' }}"
data-attendee-uri="{{ $suggestion['attendee_uri'] ?? '' }}"
data-attendee-verified="{{ !empty($suggestion['verified']) ? '1' : '0' }}"
>
<div class="font-medium text-gray-800">
{{ $suggestion['name'] ?? ($suggestion['email'] ?? '') }}
</div>
@if (!empty($suggestion['name']) && !empty($suggestion['email']))
<div class="text-sm text-gray-500">{{ $suggestion['email'] }}</div>
@endif
@if (!empty($suggestion['verified']))
<div class="text-xs text-emerald-700 mt-1">{{ __('calendar.event.attendees.verified') }}</div>
@endif
</button>
</li>
@endforeach
</ul>
@endif

View File

@ -1,26 +1,150 @@
<x-modal.content>
<x-modal.title>
{{ $event->meta->title ?? '(no title)' }}
@php
$meta = $event->meta;
$title = $meta->title ?? '(no title)';
$allDay = (bool) ($meta->all_day ?? false);
$calendarName = $calendarName ?? $calendar->displayname ?? __('common.calendar');
$rrule = $meta?->extra['rrule'] ?? null;
$tzid = $meta?->extra['tzid'] ?? $tz;
$locationLabel = $meta?->location_label ?? '';
$hasLocation = trim((string) $locationLabel) !== '';
$venue = $meta?->venue;
$addressLine1 = $venue?->street;
$addressLine2 = trim(implode(', ', array_filter([
$venue?->city,
$venue?->state,
$venue?->postal,
])));
$addressLine3 = $venue?->country;
$map = $map ?? ['enabled' => false, 'needs_key' => false, 'url' => null];
@endphp
<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>
</x-modal.title>
<x-modal.body>
<p class="text-gray-700">
{{ $start->format('l, F j, Y · g:i A') }}
@unless ($start->equalTo($end))
&nbsp;&nbsp;
{{ $end->isSameDay($start)
? $end->format('g:i A')
: $end->format('l, F j, Y · g:i A') }}
@endunless
</p>
@if ($event->meta->location)
<p><strong>Where:</strong> {{ $event->meta->location_label }}</p>
@if ($map['enabled'])
<div class="event-map" style="--event-map: url('{{ trim($map['url']) }}');" aria-label="Map of event location">
<!-- map tile is background image-->
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="h-3 w-3 rounded-full bg-magenta-500 ring-4 ring-magenta-500/20"></div>
</div>
</div>
@endif
<div class="flex flex-col gap-6">
<section class="space-y-1">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.when') }}</p>
@if ($allDay)
<p class="text-lg text-gray-900">
{{ $start->format('l, F j, Y') }}
@unless ($start->isSameDay($end))
&nbsp;&nbsp;
{{ $end->format('l, F j, Y') }}
@endunless
<span class="text-sm text-gray-500">({{ __('calendar.event.all_day') }})</span>
</p>
@else
<p class="text-lg text-gray-900">
{{ $start->format('l, F j, Y · g:i A') }}
@unless ($start->equalTo($end))
&nbsp;&nbsp;
{{ $end->isSameDay($start)
? $end->format('g:i A')
: $end->format('l, F j, Y · g:i A') }}
@endunless
</p>
@endif
<p class="text-sm text-gray-500">{{ __('common.timezone') }}: {{ $tzid }}</p>
</section>
@if ($event->meta->description)
<p>
{!! Str::markdown(nl2br(e($event->meta->description))) !!}
</p>
@endif
<section class="space-y-1">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('common.calendar') }}</p>
<p class="text-gray-900">{{ $calendarName }}</p>
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.location') }}</p>
@if ($hasLocation)
<p class="text-gray-900">{{ $locationLabel }}</p>
@if ($addressLine1 || $addressLine2 || $addressLine3)
<div class="text-sm text-gray-600">
@if ($addressLine1)
<div>{{ $addressLine1 }}</div>
@endif
@if ($addressLine2)
<div>{{ $addressLine2 }}</div>
@endif
@if ($addressLine3)
<div>{{ $addressLine3 }}</div>
@endif
</div>
@endif
@else
<p class="text-sm text-gray-500">{{ __('calendar.event.no_location') }}</p>
@endif
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.details') }}</p>
<div class="grid grid-cols-1 gap-3 text-sm text-gray-700">
<div>
<span class="text-gray-500">{{ __('calendar.event.repeats') }}:</span>
@if ($rrule)
<span class="ml-1 font-mono text-gray-800">{{ $rrule }}</span>
@else
<span class="ml-1 text-gray-500">{{ __('calendar.event.does_not_repeat') }}</span>
@endif
</div>
<div>
<span class="text-gray-500">{{ __('calendar.event.category') }}:</span>
<span class="ml-1">{{ $meta->category ?? __('calendar.event.none') }}</span>
</div>
<div>
<span class="text-gray-500">{{ __('calendar.event.visibility') }}:</span>
<span class="ml-1">{{ ($meta->is_private ?? false) ? __('calendar.event.private') : __('calendar.event.default') }}</span>
</div>
<div>
<span class="text-gray-500">{{ __('calendar.event.all_day_handling') }}:</span>
<span class="ml-1">
{{ $allDay ? __('calendar.event.all_day') : __('calendar.event.timed') }}
<span class="text-gray-400">· {{ __('calendar.event.all_day_coming') }}</span>
</span>
</div>
</div>
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.alerts') }}</p>
@if (!is_null($meta->reminder_minutes))
<p class="text-sm text-gray-700">
{{ __('calendar.event.reminder') }}: {{ $meta->reminder_minutes }} {{ __('calendar.event.minutes_before') }}
</p>
@else
<p class="text-sm text-gray-500">{{ __('calendar.event.alerts_coming') }}</p>
@endif
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.invitees') }}</p>
<p class="text-sm text-gray-500">{{ __('calendar.event.invitees_coming') }}</p>
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.attachments') }}</p>
<p class="text-sm text-gray-500">{{ __('calendar.event.attachments_coming') }}</p>
</section>
<section class="space-y-2">
<p class="text-xs uppercase tracking-wide text-gray-400">{{ __('calendar.event.notes') }}</p>
@if ($meta->description)
<div class="prose prose-sm max-w-none text-gray-800">
{!! Str::markdown(nl2br(e($meta->description))) !!}
</div>
@else
<p class="text-sm text-gray-500">{{ __('calendar.event.no_description') }}</p>
@endif
</section>
</div>
</x-modal.body>
</x-modal.content>

View File

@ -0,0 +1,35 @@
<x-modal.content modal-class="modal--event">
<x-modal.title class="input">
@if ($event->exists)
<h2>{{ __('Edit event details') }}</h2>
@else
<label for="event-natural-input" class="sr-only">Describe event</label>
<x-input.text
id="event-natural-input"
type="text"
class="input--lg"
placeholder="Start typing to create an event..."
data-natural-event-input
autofocus
/>
@endif
</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 ? __('common.save') : __('Create event') }}
</x-button>
</x-modal.footer>
</x-modal.content>

View File

@ -0,0 +1,430 @@
@php
$isModal = $isModal ?? false;
$formAction = $event->exists
? route('calendar.event.update', [$calendar, $event])
: route('calendar.event.store', $calendar);
@endphp
<form method="POST" id="event-form" action="{{ $formAction }}" class="settings modal">
@csrf
@if($event->exists)
@method('PUT')
@endif
{{-- Title --}}
<div class="event-field">
<div class="event-field-icon"><!-- empty --></div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.text
id="title"
name="title"
type="text"
:value="old('title', $event->meta?->title ?? '')"
placeholder="Event title..."
required
:autofocus="$event->exists"
/>
<x-input.error class="mt-2" :messages="$errors->get('title')" />
</div>
</div>
</div>
{{-- Calendar --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-calendar />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<div class="relative" data-calendar-picker>
<input type="hidden" id="calendar_uri" name="calendar_uri" value="{{ $selectedCalendarUri ?? '' }}" data-calendar-picker-input>
<button
type="button"
class="button button--secondary w-full justify-between"
data-calendar-picker-toggle
aria-expanded="false"
>
<span class="inline-flex items-center gap-2">
<span class="inline-block h-3 w-3 rounded-full" data-calendar-picker-color style="background-color: {{ $selectedCalendarColor ?? '#64748b' }}"></span>
<span data-calendar-picker-label>{{ $selectedCalendarName ?? __('common.calendar') }}</span>
</span>
<x-icon-chevron-down width="18" />
</button>
<div class="absolute z-20 mt-2 w-full rounded-md border-md border-secondary bg-white shadow-sm hidden" data-calendar-picker-menu>
<ul class="list-none p-1 m-0 flex flex-col gap-1">
@foreach (($calendarPickerOptions ?? []) as $option)
<li>
<button
type="button"
class="button button--tertiary w-full justify-start"
data-calendar-picker-option
data-calendar-picker-uri="{{ $option['uri'] }}"
data-calendar-picker-name="{{ $option['name'] }}"
data-calendar-picker-color="{{ $option['color'] }}"
>
<span class="inline-block h-3 w-3 rounded-full" style="background-color: {{ $option['color'] }}"></span>
<span>{{ $option['name'] }}</span>
</button>
</li>
@endforeach
</ul>
</div>
</div>
</div>
</div>
</div>
{{-- Location --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-pin />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.text
id="location"
name="location"
:value="old('location', $event->meta?->location ?? '')"
placeholder="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>
</div>
{{-- Start / End --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-calendar-clock />
</div>
<div class="input-rows">
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.text
id="start_at"
name="start_at"
type="datetime-local"
:value="old('start_at', $start)"
data-event-start
required
aria-label="Start date and time"
/>
<x-input.error :messages="$errors->get('start_at')" />
</div>
<div class="input-cell">
<x-input.text
id="end_at"
name="end_at"
type="datetime-local"
:value="old('end_at', $end)"
data-event-end
required
aria-label="End date and time"
/>
<x-input.error :messages="$errors->get('end_at')" />
</div>
</div>
<div class="input-row input-row--1">
<div class="input-cell ml-2px">
<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>
</div>
</div>
{{-- recurrence --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-repeat />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<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>
</div>
</div>
{{-- attendees --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-user-circle />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<input type="hidden" name="attendees_present" value="1">
<div class="flex flex-col gap-3" data-attendees data-next-index="{{ count($attendees ?? []) }}">
<div class="flex flex-col gap-2">
<x-input.label for="attendee_lookup" :value="__('calendar.event.attendees.add')" />
<p class="text-sm text-gray-600">{{ __('calendar.event.attendees.help') }}</p>
<div class="flex items-center gap-2">
<x-input.text
id="attendee_lookup"
name="attendee"
type="text"
placeholder="{{ __('calendar.event.attendees.search_placeholder') }}"
data-attendee-lookup
hx-get="{{ route('attendee.suggest') }}"
hx-trigger="keyup changed delay:250ms"
hx-target="#attendee-suggestions"
hx-swap="innerHTML"
/>
<x-button type="button" variant="tertiary" data-attendee-add-manual>
{{ __('calendar.event.attendees.add_button') }}
</x-button>
</div>
<div id="attendee-suggestions"></div>
</div>
<div class="flex flex-col gap-3" data-attendees-list>
@foreach (($attendees ?? []) as $index => $attendee)
<div class="attendee-row rounded-lg border border-gray-200 p-3 flex flex-col gap-3" data-attendee-row>
<input type="hidden" name="attendees[{{ $index }}][attendee_uri]" value="{{ $attendee['attendee_uri'] ?? '' }}" data-attendee-uri>
<input type="hidden" name="attendees[{{ $index }}][email]" value="{{ $attendee['email'] ?? '' }}" data-attendee-email>
<input type="hidden" name="attendees[{{ $index }}][name]" value="{{ $attendee['name'] ?? '' }}" data-attendee-name>
<input type="hidden" name="attendees[{{ $index }}][role]" value="{{ !empty($attendee['optional']) ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT' }}" data-attendee-role>
<input type="hidden" name="attendees[{{ $index }}][partstat]" value="NEEDS-ACTION">
<input type="hidden" name="attendees[{{ $index }}][cutype]" value="INDIVIDUAL">
<input type="hidden" name="attendees[{{ $index }}][is_organizer]" value="0">
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col">
<strong data-attendee-display>
{{ ($attendee['name'] ?? '') !== '' ? ($attendee['name'] . ' <' . ($attendee['email'] ?? '') . '>') : ($attendee['email'] ?? '') }}
</strong>
<span class="text-xs text-emerald-700 {{ !empty($attendee['verified']) ? '' : 'hidden' }}" data-attendee-verified>
{{ __('calendar.event.attendees.verified') }}
</span>
</div>
<button type="button" class="button button--tertiary" data-attendee-remove>
<x-icon-x width="18" />
<span class="sr-only">{{ __('calendar.event.attendees.remove') }}</span>
</button>
</div>
<div class="flex items-center gap-4">
<label class="inline-flex items-center gap-2 text-sm">
<input type="hidden" name="attendees[{{ $index }}][optional]" value="0">
<x-input.checkbox
name="attendees[{{ $index }}][optional]"
value="1"
:checked="(bool) ($attendee['optional'] ?? false)"
data-attendee-optional
/>
<span>{{ __('calendar.event.attendees.optional') }}</span>
</label>
<label class="inline-flex items-center gap-2 text-sm">
<input type="hidden" name="attendees[{{ $index }}][rsvp]" value="0">
<x-input.checkbox
name="attendees[{{ $index }}][rsvp]"
value="1"
:checked="(bool) ($attendee['rsvp'] ?? true)"
/>
<span>{{ __('calendar.event.attendees.rsvp') }}</span>
</label>
</div>
</div>
@endforeach
</div>
<template data-attendee-template>
<div class="attendee-row rounded-lg border border-gray-200 p-3 flex flex-col gap-3" data-attendee-row>
<input type="hidden" name="attendees[__INDEX__][attendee_uri]" value="" data-attendee-uri>
<input type="hidden" name="attendees[__INDEX__][email]" value="" data-attendee-email>
<input type="hidden" name="attendees[__INDEX__][name]" value="" data-attendee-name>
<input type="hidden" name="attendees[__INDEX__][role]" value="REQ-PARTICIPANT" data-attendee-role>
<input type="hidden" name="attendees[__INDEX__][partstat]" value="NEEDS-ACTION">
<input type="hidden" name="attendees[__INDEX__][cutype]" value="INDIVIDUAL">
<input type="hidden" name="attendees[__INDEX__][is_organizer]" value="0">
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col">
<strong data-attendee-display></strong>
<span class="text-xs text-emerald-700 hidden" data-attendee-verified>
{{ __('calendar.event.attendees.verified') }}
</span>
</div>
<button type="button" class="button button--tertiary" data-attendee-remove>
<x-icon-x width="18" />
<span class="sr-only">{{ __('calendar.event.attendees.remove') }}</span>
</button>
</div>
<div class="flex items-center gap-4">
<label class="inline-flex items-center gap-2 text-sm">
<input type="hidden" name="attendees[__INDEX__][optional]" value="0">
<input name="attendees[__INDEX__][optional]" type="checkbox" value="1" data-attendee-optional>
<span>{{ __('calendar.event.attendees.optional') }}</span>
</label>
<label class="inline-flex items-center gap-2 text-sm">
<input type="hidden" name="attendees[__INDEX__][rsvp]" value="0">
<input name="attendees[__INDEX__][rsvp]" type="checkbox" value="1" checked>
<span>{{ __('calendar.event.attendees.rsvp') }}</span>
</label>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
{{-- Description --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-description />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.textarea
id="description"
name="description"
placeholder="Description..."
rows="3">{{ old('description', $event->meta?->description ?? '') }}</x-input.textarea>
<x-input.error :messages="$errors->get('description')" />
</div>
</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>

View File

@ -28,9 +28,6 @@
<!-- bottom -->
<section class="bottom">
<x-button.icon type="anchor" :href="route('settings')">
<x-icon-settings class="w-7 h-7" />
</x-button.icon>
<x-button.icon type="anchor" :href="route('account.index')">
<x-icon-user-circle class="w-7 h-7" />
</x-button.icon>

View File

@ -2,6 +2,7 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AccountController;
use App\Http\Controllers\AttendeeController;
use App\Http\Controllers\BookController;
use App\Http\Controllers\CalendarController;
use App\Http\Controllers\CalendarSettingsController;
@ -127,6 +128,9 @@ Route::middleware('auth')->group(function ()
Route::get('/location/suggest', [LocationController::class, 'suggest'])
->name('location.suggest');
Route::get('/attendee/suggest', [AttendeeController::class, 'suggest'])
->name('attendee.suggest');
// address books
Route::resource('book', BookController::class)
->names('book') // books.index, books.create, …
@ -161,7 +165,8 @@ Route::match(
->withoutMiddleware([VerifyCsrfToken::class]);
// our subscriptions
Route::get('/ics/{calendarUri}.ics', [IcsController::class, 'download']);
Route::get('/ics/{calendarUri}.ics', [IcsController::class, 'download'])
->name('calendar.ics');
// remote subscriptions
Route::middleware(['auth'])->group(function () {

View File

@ -2,32 +2,35 @@
use App\Models\User;
test('profile page is displayed', function () {
test('account info page is displayed', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->get('/profile');
->get('/account/info');
$response->assertOk();
});
test('profile information can be updated', function () {
test('account information can be updated', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
->patch('/account/info', [
'firstname' => 'Test',
'lastname' => 'User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
->assertRedirect('/account/info');
$user->refresh();
$this->assertSame('Test', $user->firstname);
$this->assertSame('User', $user->lastname);
$this->assertSame('Test User', $user->name);
$this->assertSame('test@example.com', $user->email);
$this->assertNull($user->email_verified_at);
@ -38,14 +41,15 @@ test('email verification status is unchanged when the email address is unchanged
$response = $this
->actingAs($user)
->patch('/profile', [
'name' => 'Test User',
->patch('/account/info', [
'firstname' => 'Test',
'lastname' => 'User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
->assertRedirect('/account/info');
$this->assertNotNull($user->refresh()->email_verified_at);
});
@ -55,13 +59,13 @@ test('user can delete their account', function () {
$response = $this
->actingAs($user)
->delete('/profile', [
->delete('/account', [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
->assertRedirect('/dashboard');
$this->assertGuest();
$this->assertNull($user->fresh());
@ -72,14 +76,14 @@ test('correct password must be provided to delete account', function () {
$response = $this
->actingAs($user)
->from('/profile')
->delete('/profile', [
->from('/account/delete/confirm')
->delete('/account', [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrorsIn('userDeletion', 'password')
->assertRedirect('/profile');
->assertRedirect('/account/delete/confirm');
$this->assertNotNull($user->fresh());
});

View File

@ -8,7 +8,7 @@ test('password can be updated', function () {
$response = $this
->actingAs($user)
->from('/profile')
->from('/account/password')
->put('/password', [
'current_password' => 'password',
'password' => 'new-password',
@ -17,7 +17,7 @@ test('password can be updated', function () {
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
->assertRedirect('/account/password');
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
});
@ -27,7 +27,7 @@ test('correct password must be provided to update password', function () {
$response = $this
->actingAs($user)
->from('/profile')
->from('/account/password')
->put('/password', [
'current_password' => 'wrong-password',
'password' => 'new-password',
@ -36,5 +36,5 @@ test('correct password must be provided to update password', function () {
$response
->assertSessionHasErrorsIn('updatePassword', 'current_password')
->assertRedirect('/profile');
->assertRedirect('/account/password');
});

View File

@ -0,0 +1,41 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
function createTestUser(array $overrides = []): User
{
return User::forceCreate(array_merge([
'firstname' => 'Test',
'lastname' => 'User',
'displayname' => 'Test User',
'email' => 'test+'.Str::uuid().'@example.com',
'timezone' => 'America/New_York',
'password' => Hash::make('password'),
], $overrides));
}
test('calendar index handles invalid date without error', function () {
$user = createTestUser();
$this->actingAs($user)
->get(route('calendar.index', ['date' => 'not-a-date']))
->assertOk();
});
test('daytime_hours persists to user settings', function () {
$user = createTestUser();
$this->actingAs($user)
->get(route('calendar.index', ['daytime_hours' => 1]))
->assertOk();
$value = DB::table('user_settings')
->where('user_id', $user->id)
->where('key', 'calendar.daytime_hours')
->value('value');
expect($value)->toBe('1');
});

View File

@ -0,0 +1,37 @@
<?php
use App\Services\Calendar\CalendarRangeResolver;
use Carbon\Carbon;
use Illuminate\Http\Request;
test('safeDate falls back to default on invalid date', function () {
$resolver = new CalendarRangeResolver();
$tz = 'America/New_York';
$date = $resolver->safeDate('nope', $tz, '2026-02-04');
expect($date->toDateString())->toBe('2026-02-04');
});
test('resolveRange handles four-day view', function () {
$resolver = new CalendarRangeResolver();
$tz = 'America/New_York';
$request = Request::create('/calendar', 'GET', [
'view' => 'four',
'date' => '2026-02-04',
]);
[$view, $range] = $resolver->resolveRange(
$request,
$tz,
Carbon::SUNDAY,
Carbon::SATURDAY,
'month',
'2026-02-04'
);
expect($view)->toBe('four');
expect($range['start']->toDateString())->toBe('2026-02-04');
expect($range['end']->toDateString())->toBe('2026-02-07');
});

View File

@ -0,0 +1,50 @@
<?php
use App\Services\Calendar\CalendarViewBuilder;
use Carbon\Carbon;
test('timeSlots returns full day when no daytime hours', function () {
$builder = new CalendarViewBuilder();
$tz = 'America/New_York';
$dayStart = Carbon::create(2026, 2, 4, 0, 0, 0, $tz);
$slots = $builder->timeSlots($dayStart, $tz, '12');
expect($slots)->toHaveCount(96);
expect($slots[0]['key'])->toBe('00:00');
expect($slots[95]['key'])->toBe('23:45');
});
test('timeSlots respects daytime hours', function () {
$builder = new CalendarViewBuilder();
$tz = 'America/New_York';
$dayStart = Carbon::create(2026, 2, 4, 0, 0, 0, $tz);
$slots = $builder->timeSlots($dayStart, $tz, '12', ['start' => 8, 'end' => 18]);
expect($slots)->toHaveCount(40);
expect($slots[0]['key'])->toBe('08:00');
expect($slots[39]['key'])->toBe('17:45');
});
test('nowIndicator uses minute-accurate offset in daytime hours', function () {
$builder = new CalendarViewBuilder();
$tz = 'America/New_York';
Carbon::setTestNow(Carbon::create(2026, 2, 4, 11, 37, 0, $tz));
$range = [
'start' => Carbon::create(2026, 2, 4, 0, 0, 0, $tz),
'end' => Carbon::create(2026, 2, 4, 23, 59, 59, $tz),
];
$now = $builder->nowIndicator('day', $range, $tz, 15, 1, ['start' => 8, 'end' => 18]);
expect($now['show'])->toBeTrue();
expect($now['row'])->toBe(15);
expect($now['offset'])->toBeGreaterThan(0.46);
expect($now['offset'])->toBeLessThan(0.47);
expect($now['col_start'])->toBe(1);
Carbon::setTestNow();
});

View File

@ -1,5 +0,0 @@
<?php
test('that true is true', function () {
expect(true)->toBeTrue();
});