Adds new color picker component, updates calendar settings pane with calendar instance settings; fixes UTC/local TZ handling and updates seeder re same
This commit is contained in:
parent
da539d6146
commit
d58397ac17
@ -4,31 +4,29 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Calendar;
|
use App\Models\Calendar;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventMeta;
|
|
||||||
use App\Models\Location;
|
use App\Models\Location;
|
||||||
use App\Services\Location\Geocoder;
|
use App\Services\Location\Geocoder;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Sabre\VObject\Reader;
|
||||||
|
|
||||||
class EventController extends Controller
|
class EventController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* create a new event page
|
* create a new event page
|
||||||
*/
|
*/
|
||||||
public function create(Calendar $calendar, Request $request)
|
public function create(Calendar $calendar, Request $request)
|
||||||
{
|
{
|
||||||
// authorize access to this calendar
|
|
||||||
$this->authorize('update', $calendar);
|
$this->authorize('update', $calendar);
|
||||||
|
|
||||||
// the instance for the signed-in user (provides the uri/slug)
|
$instance = $calendar->instanceForUser($request->user());
|
||||||
$instance = $calendar->instanceForUser();
|
$tz = $this->displayTimezone($calendar, $request);
|
||||||
$slug = $instance?->uri ?? $calendar->id; // fallback just in case
|
|
||||||
|
|
||||||
// build a fresh event "shell" with meta defaults (keeps your view happy)
|
// build a fresh event "shell" with meta defaults
|
||||||
$event = new Event;
|
$event = new Event;
|
||||||
$event->meta = (object) [
|
$event->meta = (object) [
|
||||||
'title' => '',
|
'title' => '',
|
||||||
@ -40,9 +38,6 @@ class EventController extends Controller
|
|||||||
'category' => '',
|
'category' => '',
|
||||||
];
|
];
|
||||||
|
|
||||||
// choose a timezone and derive defaults for start/end
|
|
||||||
$tz = auth()->user()->timezone ?? config('app.timezone', 'UTC');
|
|
||||||
|
|
||||||
// if ?date=YYYY-MM-DD is present, start that day at 9am; otherwise "now"
|
// if ?date=YYYY-MM-DD is present, start that day at 9am; otherwise "now"
|
||||||
$anchor = $request->query('date')
|
$anchor = $request->query('date')
|
||||||
? Carbon::parse($request->query('date'), $tz)->startOfDay()->addHours(9)
|
? Carbon::parse($request->query('date'), $tz)->startOfDay()->addHours(9)
|
||||||
@ -50,148 +45,141 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$anchor->second(0);
|
$anchor->second(0);
|
||||||
|
|
||||||
$start_carbon = $anchor->copy();
|
$start = $anchor->copy()->format('Y-m-d\TH:i');
|
||||||
$end_carbon = $anchor->copy()->addHour();
|
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
|
||||||
|
|
||||||
// format for <input type="datetime-local">
|
|
||||||
$start = $start_carbon->format('Y-m-d\TH:i');
|
|
||||||
$end = $end_carbon->format('Y-m-d\TH:i');
|
|
||||||
|
|
||||||
return view('event.form', compact(
|
return view('event.form', compact(
|
||||||
'calendar', // bound model (so route() can take the model directly)
|
'calendar',
|
||||||
'instance', // convenience in the view
|
'instance',
|
||||||
'slug', // if you prefer passing just the slug into route()
|
|
||||||
'event',
|
'event',
|
||||||
'start',
|
'start',
|
||||||
'end'
|
'end',
|
||||||
|
'tz',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* edit event page
|
* edit event page
|
||||||
*/
|
*/
|
||||||
public function edit(Calendar $calendar, Event $event)
|
public function edit(Calendar $calendar, Event $event, Request $request)
|
||||||
{
|
{
|
||||||
$this->authorize('update', $calendar);
|
$this->authorize('update', $calendar);
|
||||||
|
|
||||||
$instance = $calendar->instanceForUser();
|
// ensure the event belongs to the parent calendar
|
||||||
$timezone = $instance?->timezone ?? 'UTC';
|
|
||||||
|
|
||||||
$start = optional($event->meta?->start_at)
|
|
||||||
?->timezone($timezone)
|
|
||||||
?->format('Y-m-d\TH:i');
|
|
||||||
|
|
||||||
$end = optional($event->meta?->end_at)
|
|
||||||
?->timezone($timezone)
|
|
||||||
?->format('Y-m-d\TH:i');
|
|
||||||
|
|
||||||
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* single event view handling
|
|
||||||
*
|
|
||||||
* URL: /calendar/{uuid}/event/{event_id}
|
|
||||||
*/
|
|
||||||
public function show(Request $request, Calendar $calendar, Event $event)
|
|
||||||
{
|
|
||||||
// ensure the event really belongs to the parent calendar
|
|
||||||
if ((int) $event->calendarid !== (int) $calendar->id) {
|
if ((int) $event->calendarid !== (int) $calendar->id) {
|
||||||
abort(Response::HTTP_NOT_FOUND);
|
abort(Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
// authorize
|
$instance = $calendar->instanceForUser($request->user());
|
||||||
$this->authorize('view', $event);
|
$tz = $this->displayTimezone($calendar, $request);
|
||||||
|
|
||||||
// eager-load metadata so the view has everything
|
|
||||||
$event->load('meta');
|
$event->load('meta');
|
||||||
$event->load('meta.venue');
|
|
||||||
|
|
||||||
// check for HTML; it sends `HX-Request: true` on every AJAX call
|
$start = $event->meta?->start_at
|
||||||
$isHtmx = $request->header('HX-Request') === 'true';
|
? Carbon::parse($event->meta->start_at)->timezone($tz)->format('Y-m-d\TH:i')
|
||||||
|
: null;
|
||||||
|
|
||||||
// convert Sabre timestamps if meta is missing
|
$end = $event->meta?->end_at
|
||||||
$start = $event->meta->start_at
|
? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i')
|
||||||
?? Carbon::createFromTimestamp($event->firstoccurence);
|
: null;
|
||||||
$end = $event->meta->end_at
|
|
||||||
?? ($event->lastoccurence
|
|
||||||
? Carbon::createFromTimestamp($event->lastoccurence)
|
|
||||||
: $start);
|
|
||||||
|
|
||||||
$data = compact('calendar', 'event', 'start', 'end');
|
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz'));
|
||||||
|
|
||||||
return $isHtmx
|
|
||||||
? view('event.partials.details', $data) // tiny fragment for the modal
|
|
||||||
: view('event.show', $data); // full-page fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BACKEND METHODS
|
* single event view handling
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
|
public function show(Request $request, Calendar $calendar, Event $event)
|
||||||
|
{
|
||||||
|
if ((int) $event->calendarid !== (int) $calendar->id) {
|
||||||
|
abort(Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorize('view', $event);
|
||||||
|
|
||||||
|
$event->load(['meta', 'meta.venue']);
|
||||||
|
|
||||||
|
$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');
|
||||||
|
|
||||||
|
$endUtc = $event->meta?->end_at
|
||||||
|
? Carbon::parse($event->meta->end_at)->utc()
|
||||||
|
: ($event->lastoccurence
|
||||||
|
? Carbon::createFromTimestamp($event->lastoccurence, 'UTC')
|
||||||
|
: $startUtc->copy());
|
||||||
|
|
||||||
|
// convert for display
|
||||||
|
$start = $startUtc->copy()->timezone($tz);
|
||||||
|
$end = $endUtc->copy()->timezone($tz);
|
||||||
|
|
||||||
|
$data = compact('calendar', 'event', 'start', 'end', 'tz');
|
||||||
|
|
||||||
|
return $isHtmx
|
||||||
|
? view('event.partials.details', $data)
|
||||||
|
: view('event.show', $data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* insert vevent into sabre’s calendarobjects + meta row
|
* insert vevent into sabre’s calendarobjects + meta row
|
||||||
*/
|
*/
|
||||||
public function store(Request $req, Calendar $calendar, Geocoder $geocoder)
|
public function store(Request $request, Calendar $calendar, Geocoder $geocoder): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->authorize('update', $calendar);
|
$this->authorize('update', $calendar);
|
||||||
|
|
||||||
$data = $req->validate([
|
$data = $request->validate([
|
||||||
'title' => 'required|string|max:200',
|
'title' => ['required', 'string', 'max:200'],
|
||||||
'start_at' => 'required|date',
|
'start_at' => ['required', 'date_format:Y-m-d\TH:i'],
|
||||||
'end_at' => 'required|date|after:start_at',
|
'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'],
|
||||||
'description' => 'nullable|string',
|
'description' => ['nullable', 'string'],
|
||||||
'location' => 'nullable|string',
|
'location' => ['nullable', 'string'],
|
||||||
'all_day' => 'sometimes|boolean',
|
'all_day' => ['sometimes', 'boolean'],
|
||||||
'category' => 'nullable|string|max:50',
|
'category' => ['nullable', 'string', 'max:50'],
|
||||||
|
|
||||||
// normalized fields from the suggestions ui (all optional)
|
// normalized location hints (optional)
|
||||||
'loc_display_name' => 'nullable|string',
|
'loc_display_name' => ['nullable', 'string'],
|
||||||
'loc_place_name' => 'nullable|string', // optional if you add this hidden input
|
'loc_place_name' => ['nullable', 'string'],
|
||||||
'loc_street' => 'nullable|string',
|
'loc_street' => ['nullable', 'string'],
|
||||||
'loc_city' => 'nullable|string',
|
'loc_city' => ['nullable', 'string'],
|
||||||
'loc_state' => 'nullable|string',
|
'loc_state' => ['nullable', 'string'],
|
||||||
'loc_postal' => 'nullable|string',
|
'loc_postal' => ['nullable', 'string'],
|
||||||
'loc_country' => 'nullable|string',
|
'loc_country' => ['nullable', 'string'],
|
||||||
'loc_lat' => 'nullable',
|
'loc_lat' => ['nullable'],
|
||||||
'loc_lon' => 'nullable',
|
'loc_lon' => ['nullable'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$tz = $this->displayTimezone($calendar, $request);
|
||||||
|
|
||||||
|
// parse input in display tz, store in utc
|
||||||
|
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
|
||||||
|
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
|
||||||
|
|
||||||
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
|
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
|
||||||
|
|
||||||
// parse local -> utc
|
$description = $this->escapeIcsText($data['description'] ?? '');
|
||||||
$clientTz = $calendar->timezone ?? 'UTC';
|
$locationStr = $this->escapeIcsText($data['location'] ?? '');
|
||||||
$start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $clientTz)->utc();
|
|
||||||
$end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $clientTz)->utc();
|
|
||||||
|
|
||||||
// normalize description/location for ics
|
// write dtstart/dtend as utc with "Z" so we have one canonical representation
|
||||||
$description = str_replace("\n", '\\n', $data['description'] ?? '');
|
|
||||||
$locationStr = str_replace("\n", '\\n', $data['location'] ?? '');
|
|
||||||
|
|
||||||
// write dtstart/dtend as utc with "Z"
|
|
||||||
$ical = <<<ICS
|
$ical = <<<ICS
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//Kithkin//Laravel CalDAV//EN
|
PRODID:-//Kithkin//Laravel CalDAV//EN
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
UID:$uid
|
UID:$uid
|
||||||
DTSTAMP:{$start->format('Ymd\THis\Z')}
|
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')}
|
||||||
DTSTART:{$start->format('Ymd\THis\Z')}
|
DTSTART:{$startUtc->format('Ymd\\THis\\Z')}
|
||||||
DTEND:{$end->format('Ymd\THis\Z')}
|
DTEND:{$endUtc->format('Ymd\\THis\\Z')}
|
||||||
SUMMARY:{$data['title']}
|
SUMMARY:{$this->escapeIcsText($data['title'])}
|
||||||
DESCRIPTION:$description
|
DESCRIPTION:$description
|
||||||
LOCATION:$locationStr
|
LOCATION:$locationStr
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR
|
END:VCALENDAR
|
||||||
|
ICS;
|
||||||
|
|
||||||
ICS;
|
|
||||||
|
|
||||||
// create sabre object
|
|
||||||
$event = Event::create([
|
$event = Event::create([
|
||||||
'calendarid' => $calendar->id,
|
'calendarid' => $calendar->id,
|
||||||
'uri' => Str::uuid() . '.ics',
|
'uri' => Str::uuid() . '.ics',
|
||||||
@ -203,116 +191,70 @@ class EventController extends Controller
|
|||||||
'calendardata' => $ical,
|
'calendardata' => $ical,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// resolve a location_id
|
$locationId = $this->resolveLocationId($request, $geocoder, $data);
|
||||||
$locationId = null;
|
|
||||||
$raw = $data['location'] ?? null;
|
|
||||||
|
|
||||||
// did the user pick a suggestion (hidden normalized fields present)?
|
|
||||||
$hasNormHints = $req->filled('loc_display_name') ||
|
|
||||||
$req->filled('loc_place_name') ||
|
|
||||||
$req->filled('loc_street') ||
|
|
||||||
$req->filled('loc_city') ||
|
|
||||||
$req->filled('loc_state') ||
|
|
||||||
$req->filled('loc_postal') ||
|
|
||||||
$req->filled('loc_country') ||
|
|
||||||
$req->filled('loc_lat') ||
|
|
||||||
$req->filled('loc_lon');
|
|
||||||
|
|
||||||
if ($raw) {
|
|
||||||
if ($hasNormHints) {
|
|
||||||
$norm = [
|
|
||||||
'display_name' => $req->input('loc_display_name') ?: $raw,
|
|
||||||
'place_name' => $req->input('loc_place_name'), // fine if null
|
|
||||||
'raw_address' => $raw,
|
|
||||||
'street' => $req->input('loc_street'),
|
|
||||||
'city' => $req->input('loc_city'),
|
|
||||||
'state' => $req->input('loc_state'),
|
|
||||||
'postal' => $req->input('loc_postal'),
|
|
||||||
'country' => $req->input('loc_country'),
|
|
||||||
'lat' => $req->filled('loc_lat') ? (float) $req->input('loc_lat') : null,
|
|
||||||
'lon' => $req->filled('loc_lon') ? (float) $req->input('loc_lon') : null,
|
|
||||||
];
|
|
||||||
$loc = Location::findOrCreateNormalized($norm, $raw);
|
|
||||||
$locationId = $loc->id;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// no hints: try geocoding the free-form string
|
|
||||||
$norm = $geocoder->forward($raw);
|
|
||||||
if ($norm) {
|
|
||||||
$loc = Location::findOrCreateNormalized($norm, $raw);
|
|
||||||
$locationId = $loc->id;
|
|
||||||
} else {
|
|
||||||
// label-only fallback so the event still links to a location row
|
|
||||||
$loc = Location::labelOnly($raw);
|
|
||||||
$locationId = $loc->id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// meta row (store raw string and link to normalized location if we have one)
|
|
||||||
$event->meta()->create([
|
$event->meta()->create([
|
||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
'location' => $raw,
|
'location' => $data['location'] ?? null,
|
||||||
'location_id' => $locationId,
|
'location_id' => $locationId,
|
||||||
'all_day' => (bool) ($data['all_day'] ?? false),
|
'all_day' => (bool) ($data['all_day'] ?? false),
|
||||||
'category' => $data['category'] ?? null,
|
'category' => $data['category'] ?? null,
|
||||||
'start_at' => $start,
|
'start_at' => $startUtc,
|
||||||
'end_at' => $end,
|
'end_at' => $endUtc,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->route('calendar.show', $calendar);
|
return redirect()->route('calendar.show', $calendar);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* update vevent + meta
|
* update vevent + meta
|
||||||
*/
|
*/
|
||||||
public function update(Request $req, Calendar $calendar, Event $event)
|
public function update(Request $request, Calendar $calendar, Event $event): RedirectResponse
|
||||||
{
|
{
|
||||||
$this->authorize('update', $calendar);
|
$this->authorize('update', $calendar);
|
||||||
|
|
||||||
$data = $req->validate([
|
if ((int) $event->calendarid !== (int) $calendar->id) {
|
||||||
'title' => 'required|string|max:200',
|
abort(Response::HTTP_NOT_FOUND);
|
||||||
'start_at' => 'required|date',
|
}
|
||||||
'end_at' => 'required|date|after:start_at',
|
|
||||||
'description' => 'nullable|string',
|
$data = $request->validate([
|
||||||
'location' => 'nullable|string',
|
'title' => ['required', 'string', 'max:200'],
|
||||||
'category' => 'nullable|string|max:50',
|
'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'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// rebuild the icalendar payload
|
$tz = $this->displayTimezone($calendar, $request);
|
||||||
$calendar_timezone = $calendar->timezone ?? 'UTC';
|
|
||||||
$start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $calendar_timezone)->setTimezone($calendar_timezone);
|
|
||||||
$end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $calendar_timezone)->setTimezone($calendar_timezone);
|
|
||||||
|
|
||||||
// prepare strings
|
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
|
||||||
$description = $data['description'] ?? '';
|
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
|
||||||
$location = $data['location'] ?? '';
|
|
||||||
$description = str_replace("\n", '\\n', $description);
|
|
||||||
$location = str_replace("\n", '\\n', $location);
|
|
||||||
|
|
||||||
// note: keep the UID stable (CalDAV relies on it!)
|
$uid = $event->uid;
|
||||||
$uid = $event->uid;
|
|
||||||
|
|
||||||
$ical = <<<ICS
|
$description = $this->escapeIcsText($data['description'] ?? '');
|
||||||
|
$locationStr = $this->escapeIcsText($data['location'] ?? '');
|
||||||
|
$summary = $this->escapeIcsText($data['title']);
|
||||||
|
|
||||||
|
$ical = <<<ICS
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//Kithkin//Laravel CalDAV//EN
|
PRODID:-//Kithkin//Laravel CalDAV//EN
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
UID:$uid
|
UID:$uid
|
||||||
DTSTAMP:{$start->utc()->format('Ymd\\THis\\Z')}
|
DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')}
|
||||||
DTSTART;TZID={$calendar_timezone}:{$start->format('Ymd\\THis')}
|
DTSTART:{$startUtc->format('Ymd\\THis\\Z')}
|
||||||
DTEND;TZID={$calendar_timezone}:{$end->format('Ymd\\THis')}
|
DTEND:{$endUtc->format('Ymd\\THis\\Z')}
|
||||||
SUMMARY:{$data['title']}
|
SUMMARY:$summary
|
||||||
DESCRIPTION:$description
|
DESCRIPTION:$description
|
||||||
LOCATION:$location
|
LOCATION:$locationStr
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR
|
END:VCALENDAR
|
||||||
|
|
||||||
ICS;
|
ICS;
|
||||||
|
|
||||||
// persist changes
|
|
||||||
$event->update([
|
$event->update([
|
||||||
'calendardata' => $ical,
|
'calendardata' => $ical,
|
||||||
'etag' => md5($ical),
|
'etag' => md5($ical),
|
||||||
@ -323,12 +265,87 @@ ICS;
|
|||||||
'title' => $data['title'],
|
'title' => $data['title'],
|
||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
'location' => $data['location'] ?? null,
|
'location' => $data['location'] ?? null,
|
||||||
'all_day' => $data['all_day'] ?? false,
|
'all_day' => (bool) ($data['all_day'] ?? false),
|
||||||
'category' => $data['category'] ?? null,
|
'category' => $data['category'] ?? null,
|
||||||
'start_at' => $start,
|
'start_at' => $startUtc,
|
||||||
'end_at' => $end,
|
'end_at' => $endUtc,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->route('calendar.show', $calendar);
|
return redirect()->route('calendar.show', $calendar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pick display timezone: calendar instance -> user -> utc
|
||||||
|
*/
|
||||||
|
private function displayTimezone(Calendar $calendar, Request $request): string
|
||||||
|
{
|
||||||
|
$instanceTz = $calendar->instanceForUser($request->user())?->timezone;
|
||||||
|
$userTz = $request->user()?->timezone;
|
||||||
|
|
||||||
|
return $instanceTz ?: ($userTz ?: 'UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parse datetime-local in tz and return utc carbon
|
||||||
|
*/
|
||||||
|
private function parseLocalDatetimeToUtc(string $value, string $tz): Carbon
|
||||||
|
{
|
||||||
|
// datetime-local: 2026-01-21T09:00
|
||||||
|
$local = Carbon::createFromFormat('Y-m-d\TH:i', $value, $tz)->seconds(0);
|
||||||
|
return $local->utc();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* minimal ics escaping for text properties
|
||||||
|
*/
|
||||||
|
private function escapeIcsText(string $text): string
|
||||||
|
{
|
||||||
|
$text = str_replace(["\r\n", "\r", "\n"], "\\n", $text);
|
||||||
|
$text = str_replace(["\\", ";", ","], ["\\\\", "\;", "\,"], $text);
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolve location_id from hints or geocoding
|
||||||
|
*/
|
||||||
|
private function resolveLocationId(Request $request, Geocoder $geocoder, array $data): ?int
|
||||||
|
{
|
||||||
|
$raw = $data['location'] ?? null;
|
||||||
|
if (!$raw) return null;
|
||||||
|
|
||||||
|
$hasNormHints = $request->filled('loc_display_name') ||
|
||||||
|
$request->filled('loc_place_name') ||
|
||||||
|
$request->filled('loc_street') ||
|
||||||
|
$request->filled('loc_city') ||
|
||||||
|
$request->filled('loc_state') ||
|
||||||
|
$request->filled('loc_postal') ||
|
||||||
|
$request->filled('loc_country') ||
|
||||||
|
$request->filled('loc_lat') ||
|
||||||
|
$request->filled('loc_lon');
|
||||||
|
|
||||||
|
if ($hasNormHints) {
|
||||||
|
$norm = [
|
||||||
|
'display_name' => $request->input('loc_display_name') ?: $raw,
|
||||||
|
'place_name' => $request->input('loc_place_name'),
|
||||||
|
'raw_address' => $raw,
|
||||||
|
'street' => $request->input('loc_street'),
|
||||||
|
'city' => $request->input('loc_city'),
|
||||||
|
'state' => $request->input('loc_state'),
|
||||||
|
'postal' => $request->input('loc_postal'),
|
||||||
|
'country' => $request->input('loc_country'),
|
||||||
|
'lat' => $request->filled('loc_lat') ? (float) $request->input('loc_lat') : null,
|
||||||
|
'lon' => $request->filled('loc_lon') ? (float) $request->input('loc_lon') : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
return Location::findOrCreateNormalized($norm, $raw)->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$norm = $geocoder->forward($raw);
|
||||||
|
|
||||||
|
if ($norm) {
|
||||||
|
return Location::findOrCreateNormalized($norm, $raw)->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Location::labelOnly($raw)->id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -154,23 +154,24 @@ class DatabaseSeeder extends Seeder
|
|||||||
* cevent creation helper function
|
* cevent creation helper function
|
||||||
**/
|
**/
|
||||||
|
|
||||||
$insertEvent = function (Carbon $start,
|
$insertEvent = function (Carbon $start, string $summary, string $locationKey) use ($calId, $locationIdMap, $locationSeeds)
|
||||||
string $summary,
|
|
||||||
string $locationKey) use ($calId, $locationIdMap)
|
|
||||||
{
|
{
|
||||||
|
// base vars (start is in your "authoring" timezone, e.g. America/New_York)
|
||||||
// set base vars
|
$uid = Str::uuid().'@kithkin.lan';
|
||||||
$uid = Str::uuid().'@kithkin.lan';
|
|
||||||
$end = $start->copy()->addHour();
|
$end = $start->copy()->addHour();
|
||||||
|
|
||||||
// create UTC copies for the ICS fields
|
// normalize once: everything stored is UTC
|
||||||
$dtstamp = $start->copy()->utc()->format('Ymd\\THis\\Z');
|
$startUtc = $start->copy()->utc();
|
||||||
$dtstart = $start->copy()->utc()->format('Ymd\\THis\\Z');
|
$endUtc = $end->copy()->utc();
|
||||||
$dtend = $end->copy()->utc()->format('Ymd\\THis\\Z');
|
|
||||||
|
// ICS fields should be UTC with Z
|
||||||
|
$dtstamp = $startUtc->format('Ymd\\THis\\Z');
|
||||||
|
$dtstart = $startUtc->format('Ymd\\THis\\Z');
|
||||||
|
$dtend = $endUtc->format('Ymd\\THis\\Z');
|
||||||
|
|
||||||
$locationDisplay = $locationKey;
|
$locationDisplay = $locationKey;
|
||||||
$locationRaw = $locationSeeds[$locationKey]['raw'] ?? null;
|
$locationRaw = $locationSeeds[$locationKey]['raw'] ?? null;
|
||||||
$icalLocation = $locationRaw ?? $locationDisplay;
|
$icalLocation = $locationRaw ?? $locationDisplay;
|
||||||
|
|
||||||
$ical = <<<ICS
|
$ical = <<<ICS
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
@ -183,7 +184,7 @@ DTSTART:$dtstart
|
|||||||
DTEND:$dtend
|
DTEND:$dtend
|
||||||
SUMMARY:$summary
|
SUMMARY:$summary
|
||||||
DESCRIPTION:Automatically seeded event
|
DESCRIPTION:Automatically seeded event
|
||||||
LOCATION:Home Office
|
LOCATION:$icalLocation
|
||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR
|
END:VCALENDAR
|
||||||
ICS;
|
ICS;
|
||||||
@ -208,8 +209,8 @@ ICS;
|
|||||||
'location_id' => $locationIdMap[$locationKey] ?? null,
|
'location_id' => $locationIdMap[$locationKey] ?? null,
|
||||||
'all_day' => false,
|
'all_day' => false,
|
||||||
'category' => 'Demo',
|
'category' => 'Demo',
|
||||||
'start_at' => $start->copy()->utc(),
|
'start_at' => $startUtc,
|
||||||
'end_at' => $end->copy()->utc(),
|
'end_at' => $endUtc,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]
|
]
|
||||||
@ -221,7 +222,8 @@ ICS;
|
|||||||
* create events
|
* create events
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$now = Carbon::now()->setSeconds(0);
|
$tz = 'America/New_York';
|
||||||
|
$now = Carbon::now($tz)->setSeconds(0);
|
||||||
|
|
||||||
// 3 events today
|
// 3 events today
|
||||||
$insertEvent($now->copy(), 'Playground with James', 'McCaHill Park');
|
$insertEvent($now->copy(), 'Playground with James', 'McCaHill Park');
|
||||||
|
|||||||
@ -16,6 +16,7 @@ return [
|
|||||||
'description' => 'Description',
|
'description' => 'Description',
|
||||||
'ics' => [
|
'ics' => [
|
||||||
'url' => 'ICS URL',
|
'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.',
|
||||||
],
|
],
|
||||||
'name' => 'Calendar name',
|
'name' => 'Calendar name',
|
||||||
'settings' => [
|
'settings' => [
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
@import './lib/button.css';
|
@import './lib/button.css';
|
||||||
@import './lib/calendar.css';
|
@import './lib/calendar.css';
|
||||||
@import './lib/checkbox.css';
|
@import './lib/checkbox.css';
|
||||||
|
@import './lib/color.css';
|
||||||
@import './lib/indicator.css';
|
@import './lib/indicator.css';
|
||||||
@import './lib/input.css';
|
@import './lib/input.css';
|
||||||
@import './lib/mini.css';
|
@import './lib/mini.css';
|
||||||
|
|||||||
@ -59,6 +59,7 @@
|
|||||||
--radius-xs: 0.25rem;
|
--radius-xs: 0.25rem;
|
||||||
--radius-sm: 0.375rem;
|
--radius-sm: 0.375rem;
|
||||||
--radius-md: 0.6667rem;
|
--radius-md: 0.6667rem;
|
||||||
|
--radius-md-inset: 0.6rem;
|
||||||
--radius-lg: 1rem;
|
--radius-lg: 1rem;
|
||||||
--radius-xl: 1.25rem;
|
--radius-xl: 1.25rem;
|
||||||
--radius-2xl: 1.5rem;
|
--radius-2xl: 1.5rem;
|
||||||
@ -68,6 +69,7 @@
|
|||||||
|
|
||||||
--shadow-drop: 2.5px 2.5px 0 0 var(--color-primary);
|
--shadow-drop: 2.5px 2.5px 0 0 var(--color-primary);
|
||||||
--shadow-input: inset 0 0.25rem 0 0 var(--color-gray-100);
|
--shadow-input: inset 0 0.25rem 0 0 var(--color-gray-100);
|
||||||
|
--shadow-input-hover: inset 0 0.25rem 0 0 var(--color-teal-200);
|
||||||
|
|
||||||
--spacing-md: 1.5px;
|
--spacing-md: 1.5px;
|
||||||
--spacing-2px: 2px;
|
--spacing-2px: 2px;
|
||||||
|
|||||||
@ -74,18 +74,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* calendar list in the left bar */
|
/* calendar list in the left bar */
|
||||||
#calendar-toggles {
|
li.calendar-toggle {
|
||||||
|
@apply relative;
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 33%);
|
||||||
|
}
|
||||||
|
|
||||||
/* show menu on hover */
|
/* show menu on hover */
|
||||||
li:hover {
|
&:hover {
|
||||||
|
.edit-link {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* limit calendar titles to 1 line */
|
/* limit calendar titles to 1 line */
|
||||||
.checkbox-label {
|
.checkbox-label span {
|
||||||
> span {
|
@apply line-clamp-1;
|
||||||
@apply line-clamp-1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
resources/css/lib/color.css
Normal file
44
resources/css/lib/color.css
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
.colorpicker {
|
||||||
|
@apply inline-flex items-center rounded-md h-11;
|
||||||
|
@apply border-md border-secondary shadow-input;
|
||||||
|
|
||||||
|
input[type="color"] {
|
||||||
|
@apply rounded-l-md-inset rounded-r-none h-full shrink-0 min-w-11;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
@apply rounded-none grow min-w-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply w-11 min-w-11 h-full shrink-0 flex items-center justify-center rounded-l-none rounded-r-md;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-teal-100 shadow-input-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
@apply pt-2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorpicker__swatch {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border, #d1d5db);
|
||||||
|
border-radius: .5rem;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorpicker__hex {
|
||||||
|
@apply w-32 font-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorpicker__random:disabled,
|
||||||
|
.colorpicker__swatch:disabled,
|
||||||
|
.colorpicker__hex:disabled {
|
||||||
|
opacity: .6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@ -8,10 +8,14 @@ input[type="url"],
|
|||||||
input[type="search"],
|
input[type="search"],
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
@apply border-md border-gray-800 bg-white rounded-md shadow-input;
|
@apply border-md border-secondary bg-white rounded-md shadow-input h-11;
|
||||||
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
|
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
|
||||||
transition: box-shadow 125ms ease-in-out,
|
transition: box-shadow 125ms ease-in-out,
|
||||||
border-color 125ms ease-in-out;
|
border-color 125ms ease-in-out;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
@apply opacity-50 cursor-not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -25,7 +29,7 @@ textarea {
|
|||||||
|
|
||||||
input[type="radio"] {
|
input[type="radio"] {
|
||||||
@apply appearance-none p-0 align-middle select-none shrink-0 rounded-full h-6 w-6;
|
@apply appearance-none p-0 align-middle select-none shrink-0 rounded-full h-6 w-6;
|
||||||
@apply text-black border-md border-gray-800 bg-none;
|
@apply text-black border-md border-secondary bg-none;
|
||||||
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
|
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
|
||||||
print-color-adjust: exact;
|
print-color-adjust: exact;
|
||||||
--radio-bg: var(--color-white);
|
--radio-bg: var(--color-white);
|
||||||
@ -71,7 +75,7 @@ label.text-label {
|
|||||||
@apply flex flex-col gap-2;
|
@apply flex flex-col gap-2;
|
||||||
|
|
||||||
> .description {
|
> .description {
|
||||||
@apply text-gray-800;
|
@apply text-secondary leading-tight px-2px text-sm xl:text-base;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.label {
|
.label {
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import './bootstrap';
|
import './bootstrap';
|
||||||
import htmx from 'htmx.org';
|
import htmx from 'htmx.org';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* htmx/global
|
||||||
|
*/
|
||||||
|
|
||||||
// make html globally visible to use the devtools and extensions
|
// make html globally visible to use the devtools and extensions
|
||||||
window.htmx = htmx;
|
window.htmx = htmx;
|
||||||
|
|
||||||
@ -14,8 +18,10 @@ document.addEventListener('htmx:configRequest', (evt) => {
|
|||||||
if (token) evt.detail.headers['X-CSRF-TOKEN'] = token
|
if (token) evt.detail.headers['X-CSRF-TOKEN'] = token
|
||||||
})
|
})
|
||||||
|
|
||||||
// calendar toggle
|
/**
|
||||||
// * progressive enhancement on html form with no js
|
* calendar toggle
|
||||||
|
* progressive enhancement on html form with no js
|
||||||
|
*/
|
||||||
document.addEventListener('change', event => {
|
document.addEventListener('change', event => {
|
||||||
const checkbox = event.target;
|
const checkbox = event.target;
|
||||||
|
|
||||||
@ -30,3 +36,102 @@ document.addEventListener('change', event => {
|
|||||||
.querySelectorAll(`[data-calendar="${slug}"]`)
|
.querySelectorAll(`[data-calendar="${slug}"]`)
|
||||||
.forEach(el => el.classList.toggle('hidden', !show));
|
.forEach(el => el.classList.toggle('hidden', !show));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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());
|
||||||
|
|
||||||
|
const normalize = (v) => {
|
||||||
|
let s = (v || '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (!s.startsWith('#')) s = '#' + s;
|
||||||
|
if (!isHex(s)) return null;
|
||||||
|
return s.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickRandom = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
|
||||||
|
const wire = (el) => {
|
||||||
|
// avoid double-binding when htmx swaps
|
||||||
|
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]');
|
||||||
|
|
||||||
|
if (!color || !hex) return;
|
||||||
|
|
||||||
|
let palette = [];
|
||||||
|
try {
|
||||||
|
palette = JSON.parse(el.getAttribute('data-palette') || '[]');
|
||||||
|
} catch {
|
||||||
|
palette = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = (val) => {
|
||||||
|
const n = normalize(val);
|
||||||
|
if (!n) return false;
|
||||||
|
|
||||||
|
color.value = n;
|
||||||
|
hex.value = n;
|
||||||
|
|
||||||
|
// bubble input/change for any listeners (htmx, previews, etc.)
|
||||||
|
color.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
color.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// init sync from native input
|
||||||
|
hex.value = normalize(color.value) || '#000000';
|
||||||
|
|
||||||
|
// native picker -> hex field
|
||||||
|
color.addEventListener('input', () => {
|
||||||
|
const n = normalize(color.value);
|
||||||
|
if (n) hex.value = n;
|
||||||
|
});
|
||||||
|
|
||||||
|
// hex typing -> native picker (on blur + Enter)
|
||||||
|
const commitHex = () => {
|
||||||
|
const ok = setValue(hex.value);
|
||||||
|
if (!ok) hex.value = normalize(color.value) || hex.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
hex.addEventListener('blur', commitHex);
|
||||||
|
hex.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
commitHex();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// random button
|
||||||
|
if (btn && palette.length) {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault(); // defensive: never submit, never navigate
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
let next = pickRandom(palette);
|
||||||
|
if (palette.length > 1) {
|
||||||
|
const current = normalize(color.value);
|
||||||
|
// avoid re-rolling the same number if possible
|
||||||
|
while (normalize(next) === current) next = pickRandom(palette);
|
||||||
|
}
|
||||||
|
setValue(next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
root.querySelectorAll('[data-colorpicker]').forEach(wire);
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial bind
|
||||||
|
document.addEventListener('DOMContentLoaded', () => initColorPickers());
|
||||||
|
|
||||||
|
// rebind in htmx for swapped content
|
||||||
|
document.addEventListener('htmx:afterSwap', (e) => {
|
||||||
|
initColorPickers(e.target);
|
||||||
|
});
|
||||||
|
|||||||
1
resources/svg/icons/d20.svg
Normal file
1
resources/svg/icons/d20.svg
Normal 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="M14.512,1.464c0.207,-0.055 0.428,0.004 0.58,0.155l7.288,7.288c0.152,0.152 0.211,0.372 0.155,0.58l-2.668,9.956c-0.055,0.207 -0.217,0.369 -0.424,0.424l-9.956,2.668c-0.207,0.056 -0.428,-0.004 -0.58,-0.155l-7.288,-7.288c-0.152,-0.152 -0.211,-0.373 -0.155,-0.58l2.668,-9.956c0.055,-0.207 0.217,-0.369 0.424,-0.424l9.956,-2.668Z" /><path d="M16.053,16.192l-9.659,-2.588l6.9,-6.433l2.759,9.022Z" /><path d="M4.247,4.229l9.047,2.942" /><path d="M4.247,4.229l2.147,9.375" /><path d="M22.6,9.146l-6.547,7.046" /><path d="M22.6,9.146l-9.306,-1.976l1.553,-5.796" /><path d="M19.753,19.771l-3.7,-3.58" /><path d="M1.4,14.854l4.994,-1.25" /><path d="M6.394,13.604l2.759,9.022l6.9,-6.433" /></svg>
|
||||||
|
After Width: | Height: | Size: 872 B |
@ -13,7 +13,7 @@
|
|||||||
<summary>{{ __('My Calendars') }}</summary>
|
<summary>{{ __('My Calendars') }}</summary>
|
||||||
<ul class="content">
|
<ul class="content">
|
||||||
@foreach ($calendars->where('is_remote', false) as $cal)
|
@foreach ($calendars->where('is_remote', false) as $cal)
|
||||||
<li>
|
<li class="calendar-toggle">
|
||||||
<x-input.checkbox-label
|
<x-input.checkbox-label
|
||||||
label="{{ $cal['name'] }}"
|
label="{{ $cal['name'] }}"
|
||||||
name="c[]"
|
name="c[]"
|
||||||
@ -22,6 +22,9 @@
|
|||||||
checkclass="calendar-toggle"
|
checkclass="calendar-toggle"
|
||||||
style="--checkbox-color: {{ $cal['color'] }}"
|
style="--checkbox-color: {{ $cal['color'] }}"
|
||||||
/>
|
/>
|
||||||
|
<a href="{{ route('calendar.settings.calendars.show', $cal['slug']) }}"
|
||||||
|
aria-label="Manage calendar"
|
||||||
|
class="edit-link">Edit</a>
|
||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
@ -39,7 +42,7 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<ul class="content">
|
<ul class="content">
|
||||||
@foreach ($calendars->where('is_remote', true) as $cal)
|
@foreach ($calendars->where('is_remote', true) as $cal)
|
||||||
<li>
|
<li class="calendar-toggle">
|
||||||
<x-input.checkbox-label
|
<x-input.checkbox-label
|
||||||
label="{{ $cal['name'] }}"
|
label="{{ $cal['name'] }}"
|
||||||
name="c[]"
|
name="c[]"
|
||||||
@ -48,6 +51,9 @@
|
|||||||
checkclass="calendar-toggle"
|
checkclass="calendar-toggle"
|
||||||
style="--checkbox-color: {{ $cal['color'] }}"
|
style="--checkbox-color: {{ $cal['color'] }}"
|
||||||
/>
|
/>
|
||||||
|
<a href="{{ route('calendar.settings.calendars.show', $cal['slug']) }}"
|
||||||
|
aria-label="Manage calendar"
|
||||||
|
class="edit-link">Edit</a>
|
||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -66,10 +66,11 @@
|
|||||||
|
|
||||||
<div class="input-cell">
|
<div class="input-cell">
|
||||||
<x-input.label for="color" :value="__('calendar.color')" />
|
<x-input.label for="color" :value="__('calendar.color')" />
|
||||||
<x-input.text id="color"
|
<x-input.color-picker
|
||||||
name="color"
|
name="color"
|
||||||
type="color"
|
:value="$color"
|
||||||
:value="$color" />
|
id="color"
|
||||||
|
/>
|
||||||
<x-input.error :messages="$errors->get('color')" />
|
<x-input.error :messages="$errors->get('color')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -84,10 +85,8 @@
|
|||||||
type="url"
|
type="url"
|
||||||
:value="$data['icsUrl'] ?? ''"
|
:value="$data['icsUrl'] ?? ''"
|
||||||
disabled="true"
|
disabled="true"
|
||||||
:description="__('calendar.settings.calendar.ics_url_help')"
|
:description="__('calendar.ics.url_help')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@ -96,6 +95,6 @@
|
|||||||
<x-button variant="primary" type="submit">{{ __('common.save_changes') }}</x-button>
|
<x-button variant="primary" type="submit">{{ __('common.save_changes') }}</x-button>
|
||||||
<x-button type="anchor"
|
<x-button type="anchor"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
href="{{ route('calendar.settings') }}">{{ __('common.cancel') }}</x-button>
|
href="{{ route('calendar.settings.calendars.show', $data['instance']['uri']) }}">{{ __('common.cancel') }}</x-button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -26,14 +26,8 @@ $element = match ($type) {
|
|||||||
default => 'button',
|
default => 'button',
|
||||||
};
|
};
|
||||||
|
|
||||||
$type = match ($type) {
|
|
||||||
'anchor' => '',
|
|
||||||
'button' => 'type="button"',
|
|
||||||
'submit' => 'type="submit"',
|
|
||||||
default => '',
|
|
||||||
}
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<{{ $element }} {{ $type }} class="button button--icon {{ $variantClass }} {{ $sizeClass }} {{ $class }}" aria-label="{{ $label }}" href="{{ $href }}">
|
<{{ $element }} class="button button--icon {{ $variantClass }} {{ $sizeClass }} {{ $class }}" aria-label="{{ $label }}" href="{{ $href }}">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</{{ $element }}>
|
</{{ $element }}>
|
||||||
|
|||||||
54
resources/views/components/input/color-picker.blade.php
Normal file
54
resources/views/components/input/color-picker.blade.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
@props([
|
||||||
|
'id' => null,
|
||||||
|
'name' => 'color',
|
||||||
|
'value' => null, // initial hex, e.g. "#0038ff"
|
||||||
|
'palette' => null, // optional override array of hex strings
|
||||||
|
'required' => false,
|
||||||
|
'disabled' => false,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$id = $id ?: 'color_'.Str::uuid();
|
||||||
|
$initial = old($name, $value) ?: '#1a1a1a';
|
||||||
|
|
||||||
|
// palette of pre-selected colors to cycle through
|
||||||
|
$pleasant = $palette ?: [
|
||||||
|
'#2563eb', '#7c3aed', '#db2777', '#ef4444', '#f97316',
|
||||||
|
'#f59e0b', '#84cc16', '#22c55e', '#14b8a6', '#06b6d4',
|
||||||
|
'#0ea5e9', '#64748b', '#d051cc', '#ffec05', '#739399',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="colorpicker" data-colorpicker data-palette='@json(array_values($pleasant))'>
|
||||||
|
<input
|
||||||
|
id="{{ $id }}"
|
||||||
|
type="color"
|
||||||
|
name="{{ $name }}"
|
||||||
|
value="{{ $initial }}"
|
||||||
|
@required($required)
|
||||||
|
@disabled($disabled)
|
||||||
|
data-colorpicker-color
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="colorpicker__hex"
|
||||||
|
value="{{ strtoupper($initial) }}"
|
||||||
|
aria-label="{{ __('Hex color') }}"
|
||||||
|
placeholder="#RRGGBB"
|
||||||
|
@disabled($disabled)
|
||||||
|
data-colorpicker-hex
|
||||||
|
/>
|
||||||
|
<x-button
|
||||||
|
variant="tertiary"
|
||||||
|
class="colorpicker__random"
|
||||||
|
:label="__('Random color')"
|
||||||
|
:title="__('Random color')"
|
||||||
|
data-colorpicker-random
|
||||||
|
>
|
||||||
|
<x-icon-d20 width="20" />
|
||||||
|
</x-button>
|
||||||
|
|
||||||
|
</div>
|
||||||
@ -31,7 +31,7 @@
|
|||||||
@foreach ($calendars as $cal)
|
@foreach ($calendars as $cal)
|
||||||
<li>
|
<li>
|
||||||
<x-app.calendarlink
|
<x-app.calendarlink
|
||||||
href="{{ route('calendar.settings.calendars.show', $cal->uri) }}"
|
:href="route('calendar.settings.calendars.show', $cal->uri)"
|
||||||
:active="request()->routeIs('calendar.settings.calendars.show') && request()->route('calendarUri') === $cal->uri"
|
:active="request()->routeIs('calendar.settings.calendars.show') && request()->route('calendarUri') === $cal->uri"
|
||||||
:label="$cal['displayname']"
|
:label="$cal['displayname']"
|
||||||
icon="circle-small"
|
icon="circle-small"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user