From d58397ac17043078ebd3ca49b5465f9abf79be8b Mon Sep 17 00:00:00 2001
From: Andrew Gioia
Date: Fri, 23 Jan 2026 10:56:21 -0500
Subject: [PATCH] Adds new color picker component, updates calendar settings
pane with calendar instance settings; fixes UTC/local TZ handling and updates
seeder re same
---
app/Http/Controllers/EventController.php | 401 +++++++++---------
database/seeders/DatabaseSeeder.php | 34 +-
lang/en/calendar.php | 1 +
resources/css/app.css | 1 +
resources/css/etc/theme.css | 2 +
resources/css/lib/calendar.css | 21 +-
resources/css/lib/color.css | 44 ++
resources/css/lib/input.css | 10 +-
resources/js/app.js | 109 ++++-
resources/svg/icons/d20.svg | 1 +
resources/views/calendar/index.blade.php | 10 +-
.../calendar/settings/calendar.blade.php | 15 +-
.../views/components/button/icon.blade.php | 8 +-
.../components/input/color-picker.blade.php | 54 +++
.../menu/calendar-settings.blade.php | 2 +-
15 files changed, 475 insertions(+), 238 deletions(-)
create mode 100644 resources/css/lib/color.css
create mode 100644 resources/svg/icons/d20.svg
create mode 100644 resources/views/components/input/color-picker.blade.php
diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php
index 0ccc78c..8cf24df 100644
--- a/app/Http/Controllers/EventController.php
+++ b/app/Http/Controllers/EventController.php
@@ -4,31 +4,29 @@ namespace App\Http\Controllers;
use App\Models\Calendar;
use App\Models\Event;
-use App\Models\EventMeta;
use App\Models\Location;
use App\Services\Location\Geocoder;
use Carbon\Carbon;
+use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
-use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
+use Symfony\Component\HttpFoundation\Response;
+use Sabre\VObject\Reader;
class EventController extends Controller
{
/**
- *
* create a new event page
*/
public function create(Calendar $calendar, Request $request)
{
- // authorize access to this calendar
$this->authorize('update', $calendar);
- // the instance for the signed-in user (provides the uri/slug)
- $instance = $calendar->instanceForUser();
- $slug = $instance?->uri ?? $calendar->id; // fallback just in case
+ $instance = $calendar->instanceForUser($request->user());
+ $tz = $this->displayTimezone($calendar, $request);
- // build a fresh event "shell" with meta defaults (keeps your view happy)
+ // build a fresh event "shell" with meta defaults
$event = new Event;
$event->meta = (object) [
'title' => '',
@@ -40,9 +38,6 @@ class EventController extends Controller
'category' => '',
];
- // choose a timezone and derive defaults for start/end
- $tz = auth()->user()->timezone ?? config('app.timezone', 'UTC');
-
// if ?date=YYYY-MM-DD is present, start that day at 9am; otherwise "now"
$anchor = $request->query('date')
? Carbon::parse($request->query('date'), $tz)->startOfDay()->addHours(9)
@@ -50,148 +45,141 @@ class EventController extends Controller
$anchor->second(0);
- $start_carbon = $anchor->copy();
- $end_carbon = $anchor->copy()->addHour();
-
- // format for
- $start = $start_carbon->format('Y-m-d\TH:i');
- $end = $end_carbon->format('Y-m-d\TH:i');
+ $start = $anchor->copy()->format('Y-m-d\TH:i');
+ $end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
return view('event.form', compact(
- 'calendar', // bound model (so route() can take the model directly)
- 'instance', // convenience in the view
- 'slug', // if you prefer passing just the slug into route()
+ 'calendar',
+ 'instance',
'event',
'start',
- 'end'
+ 'end',
+ 'tz',
));
}
/**
- *
* edit event page
*/
- public function edit(Calendar $calendar, Event $event)
+ public function edit(Calendar $calendar, Event $event, Request $request)
{
$this->authorize('update', $calendar);
- $instance = $calendar->instanceForUser();
- $timezone = $instance?->timezone ?? 'UTC';
-
- $start = optional($event->meta?->start_at)
- ?->timezone($timezone)
- ?->format('Y-m-d\TH:i');
-
- $end = optional($event->meta?->end_at)
- ?->timezone($timezone)
- ?->format('Y-m-d\TH:i');
-
- return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end'));
- }
-
- /**
- *
- * single event view handling
- *
- * URL: /calendar/{uuid}/event/{event_id}
- */
- public function show(Request $request, Calendar $calendar, Event $event)
- {
- // ensure the event really belongs to the parent calendar
+ // ensure the event belongs to the parent calendar
if ((int) $event->calendarid !== (int) $calendar->id) {
abort(Response::HTTP_NOT_FOUND);
}
- // authorize
- $this->authorize('view', $event);
+ $instance = $calendar->instanceForUser($request->user());
+ $tz = $this->displayTimezone($calendar, $request);
- // eager-load metadata so the view has everything
$event->load('meta');
- $event->load('meta.venue');
- // check for HTML; it sends `HX-Request: true` on every AJAX call
- $isHtmx = $request->header('HX-Request') === 'true';
+ $start = $event->meta?->start_at
+ ? Carbon::parse($event->meta->start_at)->timezone($tz)->format('Y-m-d\TH:i')
+ : null;
- // convert Sabre timestamps if meta is missing
- $start = $event->meta->start_at
- ?? Carbon::createFromTimestamp($event->firstoccurence);
- $end = $event->meta->end_at
- ?? ($event->lastoccurence
- ? Carbon::createFromTimestamp($event->lastoccurence)
- : $start);
+ $end = $event->meta?->end_at
+ ? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i')
+ : null;
- $data = compact('calendar', 'event', 'start', 'end');
-
- return $isHtmx
- ? view('event.partials.details', $data) // tiny fragment for the modal
- : view('event.show', $data); // full-page fallback
+ return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz'));
}
-
/**
- * 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
*/
- public function store(Request $req, Calendar $calendar, Geocoder $geocoder)
+ public function store(Request $request, Calendar $calendar, Geocoder $geocoder): RedirectResponse
{
$this->authorize('update', $calendar);
- $data = $req->validate([
- 'title' => 'required|string|max:200',
- 'start_at' => 'required|date',
- 'end_at' => 'required|date|after:start_at',
- 'description' => 'nullable|string',
- 'location' => 'nullable|string',
- 'all_day' => 'sometimes|boolean',
- 'category' => 'nullable|string|max:50',
+ $data = $request->validate([
+ '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'],
- // normalized fields from the suggestions ui (all optional)
- 'loc_display_name' => 'nullable|string',
- 'loc_place_name' => 'nullable|string', // optional if you add this hidden input
- 'loc_street' => 'nullable|string',
- 'loc_city' => 'nullable|string',
- 'loc_state' => 'nullable|string',
- 'loc_postal' => 'nullable|string',
- 'loc_country' => 'nullable|string',
- 'loc_lat' => 'nullable',
- 'loc_lon' => 'nullable',
+ // normalized location hints (optional)
+ 'loc_display_name' => ['nullable', 'string'],
+ 'loc_place_name' => ['nullable', 'string'],
+ 'loc_street' => ['nullable', 'string'],
+ 'loc_city' => ['nullable', 'string'],
+ 'loc_state' => ['nullable', 'string'],
+ 'loc_postal' => ['nullable', 'string'],
+ 'loc_country' => ['nullable', 'string'],
+ 'loc_lat' => ['nullable'],
+ 'loc_lon' => ['nullable'],
]);
+ $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);
- // parse local -> utc
- $clientTz = $calendar->timezone ?? 'UTC';
- $start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $clientTz)->utc();
- $end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $clientTz)->utc();
+ $description = $this->escapeIcsText($data['description'] ?? '');
+ $locationStr = $this->escapeIcsText($data['location'] ?? '');
- // normalize description/location for ics
- $description = str_replace("\n", '\\n', $data['description'] ?? '');
- $locationStr = str_replace("\n", '\\n', $data['location'] ?? '');
-
- // write dtstart/dtend as utc with "Z"
+ // write dtstart/dtend as utc with "Z" so we have one canonical representation
$ical = <<format('Ymd\THis\Z')}
- DTSTART:{$start->format('Ymd\THis\Z')}
- DTEND:{$end->format('Ymd\THis\Z')}
- SUMMARY:{$data['title']}
- DESCRIPTION:$description
- LOCATION:$locationStr
- END:VEVENT
- END:VCALENDAR
+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;
- ICS;
-
- // create sabre object
$event = Event::create([
'calendarid' => $calendar->id,
'uri' => Str::uuid() . '.ics',
@@ -203,116 +191,70 @@ class EventController extends Controller
'calendardata' => $ical,
]);
- // resolve a location_id
- $locationId = null;
- $raw = $data['location'] ?? null;
+ $locationId = $this->resolveLocationId($request, $geocoder, $data);
- // did the user pick a suggestion (hidden normalized fields present)?
- $hasNormHints = $req->filled('loc_display_name') ||
- $req->filled('loc_place_name') ||
- $req->filled('loc_street') ||
- $req->filled('loc_city') ||
- $req->filled('loc_state') ||
- $req->filled('loc_postal') ||
- $req->filled('loc_country') ||
- $req->filled('loc_lat') ||
- $req->filled('loc_lon');
-
- if ($raw) {
- if ($hasNormHints) {
- $norm = [
- 'display_name' => $req->input('loc_display_name') ?: $raw,
- 'place_name' => $req->input('loc_place_name'), // fine if null
- 'raw_address' => $raw,
- 'street' => $req->input('loc_street'),
- 'city' => $req->input('loc_city'),
- 'state' => $req->input('loc_state'),
- 'postal' => $req->input('loc_postal'),
- 'country' => $req->input('loc_country'),
- 'lat' => $req->filled('loc_lat') ? (float) $req->input('loc_lat') : null,
- 'lon' => $req->filled('loc_lon') ? (float) $req->input('loc_lon') : null,
- ];
- $loc = Location::findOrCreateNormalized($norm, $raw);
- $locationId = $loc->id;
-
- } else {
- // no hints: try geocoding the free-form string
- $norm = $geocoder->forward($raw);
- if ($norm) {
- $loc = Location::findOrCreateNormalized($norm, $raw);
- $locationId = $loc->id;
- } else {
- // label-only fallback so the event still links to a location row
- $loc = Location::labelOnly($raw);
- $locationId = $loc->id;
- }
- }
- }
-
- // meta row (store raw string and link to normalized location if we have one)
$event->meta()->create([
'title' => $data['title'],
'description' => $data['description'] ?? null,
- 'location' => $raw,
+ 'location' => $data['location'] ?? null,
'location_id' => $locationId,
'all_day' => (bool) ($data['all_day'] ?? false),
'category' => $data['category'] ?? null,
- 'start_at' => $start,
- 'end_at' => $end,
+ 'start_at' => $startUtc,
+ 'end_at' => $endUtc,
]);
return redirect()->route('calendar.show', $calendar);
}
/**
- *
* 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);
- $data = $req->validate([
- 'title' => 'required|string|max:200',
- 'start_at' => 'required|date',
- 'end_at' => 'required|date|after:start_at',
- 'description' => 'nullable|string',
- 'location' => 'nullable|string',
- 'category' => 'nullable|string|max:50',
+ if ((int) $event->calendarid !== (int) $calendar->id) {
+ abort(Response::HTTP_NOT_FOUND);
+ }
+
+ $data = $request->validate([
+ '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'],
]);
- // rebuild the icalendar payload
- $calendar_timezone = $calendar->timezone ?? 'UTC';
- $start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $calendar_timezone)->setTimezone($calendar_timezone);
- $end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $calendar_timezone)->setTimezone($calendar_timezone);
+ $tz = $this->displayTimezone($calendar, $request);
- // prepare strings
- $description = $data['description'] ?? '';
- $location = $data['location'] ?? '';
- $description = str_replace("\n", '\\n', $description);
- $location = str_replace("\n", '\\n', $location);
+ $startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
+ $endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
- // note: keep the UID stable (CalDAV relies on it!)
- $uid = $event->uid;
+ $uid = $event->uid;
- $ical = <<escapeIcsText($data['description'] ?? '');
+ $locationStr = $this->escapeIcsText($data['location'] ?? '');
+ $summary = $this->escapeIcsText($data['title']);
+
+ $ical = <<utc()->format('Ymd\\THis\\Z')}
-DTSTART;TZID={$calendar_timezone}:{$start->format('Ymd\\THis')}
-DTEND;TZID={$calendar_timezone}:{$end->format('Ymd\\THis')}
-SUMMARY:{$data['title']}
+DTSTAMP:{$startUtc->format('Ymd\\THis\\Z')}
+DTSTART:{$startUtc->format('Ymd\\THis\\Z')}
+DTEND:{$endUtc->format('Ymd\\THis\\Z')}
+SUMMARY:$summary
DESCRIPTION:$description
-LOCATION:$location
+LOCATION:$locationStr
END:VEVENT
END:VCALENDAR
-
ICS;
- // persist changes
$event->update([
'calendardata' => $ical,
'etag' => md5($ical),
@@ -323,12 +265,87 @@ ICS;
'title' => $data['title'],
'description' => $data['description'] ?? null,
'location' => $data['location'] ?? null,
- 'all_day' => $data['all_day'] ?? false,
+ 'all_day' => (bool) ($data['all_day'] ?? false),
'category' => $data['category'] ?? null,
- 'start_at' => $start,
- 'end_at' => $end,
+ 'start_at' => $startUtc,
+ 'end_at' => $endUtc,
]);
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;
+ }
}
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index bf2d3f7..1681548 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -154,23 +154,24 @@ class DatabaseSeeder extends Seeder
* cevent creation helper function
**/
- $insertEvent = function (Carbon $start,
- string $summary,
- string $locationKey) use ($calId, $locationIdMap)
+ $insertEvent = function (Carbon $start, string $summary, string $locationKey) use ($calId, $locationIdMap, $locationSeeds)
{
-
- // set base vars
- $uid = Str::uuid().'@kithkin.lan';
+ // base vars (start is in your "authoring" timezone, e.g. America/New_York)
+ $uid = Str::uuid().'@kithkin.lan';
$end = $start->copy()->addHour();
- // create UTC copies for the ICS fields
- $dtstamp = $start->copy()->utc()->format('Ymd\\THis\\Z');
- $dtstart = $start->copy()->utc()->format('Ymd\\THis\\Z');
- $dtend = $end->copy()->utc()->format('Ymd\\THis\\Z');
+ // normalize once: everything stored is UTC
+ $startUtc = $start->copy()->utc();
+ $endUtc = $end->copy()->utc();
+
+ // 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;
- $locationRaw = $locationSeeds[$locationKey]['raw'] ?? null;
- $icalLocation = $locationRaw ?? $locationDisplay;
+ $locationRaw = $locationSeeds[$locationKey]['raw'] ?? null;
+ $icalLocation = $locationRaw ?? $locationDisplay;
$ical = << $locationIdMap[$locationKey] ?? null,
'all_day' => false,
'category' => 'Demo',
- 'start_at' => $start->copy()->utc(),
- 'end_at' => $end->copy()->utc(),
+ 'start_at' => $startUtc,
+ 'end_at' => $endUtc,
'created_at' => now(),
'updated_at' => now(),
]
@@ -221,7 +222,8 @@ ICS;
* create events
*/
- $now = Carbon::now()->setSeconds(0);
+ $tz = 'America/New_York';
+ $now = Carbon::now($tz)->setSeconds(0);
// 3 events today
$insertEvent($now->copy(), 'Playground with James', 'McCaHill Park');
diff --git a/lang/en/calendar.php b/lang/en/calendar.php
index 7a6731c..59d9052 100644
--- a/lang/en/calendar.php
+++ b/lang/en/calendar.php
@@ -16,6 +16,7 @@ return [
'description' => 'Description',
'ics' => [
'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',
'settings' => [
diff --git a/resources/css/app.css b/resources/css/app.css
index 925ee4e..4856a38 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -9,6 +9,7 @@
@import './lib/button.css';
@import './lib/calendar.css';
@import './lib/checkbox.css';
+@import './lib/color.css';
@import './lib/indicator.css';
@import './lib/input.css';
@import './lib/mini.css';
diff --git a/resources/css/etc/theme.css b/resources/css/etc/theme.css
index 17abed5..8515977 100644
--- a/resources/css/etc/theme.css
+++ b/resources/css/etc/theme.css
@@ -59,6 +59,7 @@
--radius-xs: 0.25rem;
--radius-sm: 0.375rem;
--radius-md: 0.6667rem;
+ --radius-md-inset: 0.6rem;
--radius-lg: 1rem;
--radius-xl: 1.25rem;
--radius-2xl: 1.5rem;
@@ -68,6 +69,7 @@
--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-hover: inset 0 0.25rem 0 0 var(--color-teal-200);
--spacing-md: 1.5px;
--spacing-2px: 2px;
diff --git a/resources/css/lib/calendar.css b/resources/css/lib/calendar.css
index cc684ad..fb61783 100644
--- a/resources/css/lib/calendar.css
+++ b/resources/css/lib/calendar.css
@@ -74,18 +74,25 @@
}
/* 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 */
- li:hover {
-
+ &:hover {
+ .edit-link {
+ @apply block;
+ }
}
/* limit calendar titles to 1 line */
- .checkbox-label {
- > span {
- @apply line-clamp-1;
- }
+ .checkbox-label span {
+ @apply line-clamp-1;
}
}
diff --git a/resources/css/lib/color.css b/resources/css/lib/color.css
new file mode 100644
index 0000000..1bdcbfb
--- /dev/null
+++ b/resources/css/lib/color.css
@@ -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;
+}
diff --git a/resources/css/lib/input.css b/resources/css/lib/input.css
index 5f29236..d5436ed 100644
--- a/resources/css/lib/input.css
+++ b/resources/css/lib/input.css
@@ -8,10 +8,14 @@ input[type="url"],
input[type="search"],
select,
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;
transition: box-shadow 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"] {
@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;
print-color-adjust: exact;
--radio-bg: var(--color-white);
@@ -71,7 +75,7 @@ label.text-label {
@apply flex flex-col gap-2;
> .description {
- @apply text-gray-800;
+ @apply text-secondary leading-tight px-2px text-sm xl:text-base;
}
}
.label {
diff --git a/resources/js/app.js b/resources/js/app.js
index 4295696..4d9d64d 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,6 +1,10 @@
import './bootstrap';
import htmx from 'htmx.org';
+/**
+ * htmx/global
+ */
+
// make html globally visible to use the devtools and extensions
window.htmx = htmx;
@@ -14,8 +18,10 @@ document.addEventListener('htmx:configRequest', (evt) => {
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 => {
const checkbox = event.target;
@@ -30,3 +36,102 @@ document.addEventListener('change', event => {
.querySelectorAll(`[data-calendar="${slug}"]`)
.forEach(el => el.classList.toggle('hidden', !show));
});
+
+/**
+ * color picker component
+ * native + 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);
+});
diff --git a/resources/svg/icons/d20.svg b/resources/svg/icons/d20.svg
new file mode 100644
index 0000000..bf99d98
--- /dev/null
+++ b/resources/svg/icons/d20.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/views/calendar/index.blade.php b/resources/views/calendar/index.blade.php
index fcd3531..a4b4ace 100644
--- a/resources/views/calendar/index.blade.php
+++ b/resources/views/calendar/index.blade.php
@@ -13,7 +13,7 @@
{{ __('My Calendars') }}
@foreach ($calendars->where('is_remote', false) as $cal)
- -
+
-
+ Edit
@endforeach
@@ -39,7 +42,7 @@
@foreach ($calendars->where('is_remote', true) as $cal)
- -
+
-
+ Edit
@endforeach
diff --git a/resources/views/calendar/settings/calendar.blade.php b/resources/views/calendar/settings/calendar.blade.php
index b70e826..4b79621 100644
--- a/resources/views/calendar/settings/calendar.blade.php
+++ b/resources/views/calendar/settings/calendar.blade.php
@@ -66,10 +66,11 @@
-
+
@@ -84,10 +85,8 @@
type="url"
:value="$data['icsUrl'] ?? ''"
disabled="true"
- :description="__('calendar.settings.calendar.ics_url_help')"
+ :description="__('calendar.ics.url_help')"
/>
-
-
@endif
@@ -96,6 +95,6 @@
{{ __('common.save_changes') }}
{{ __('common.cancel') }}
+ href="{{ route('calendar.settings.calendars.show', $data['instance']['uri']) }}">{{ __('common.cancel') }}
diff --git a/resources/views/components/button/icon.blade.php b/resources/views/components/button/icon.blade.php
index dfdc8db..0883611 100644
--- a/resources/views/components/button/icon.blade.php
+++ b/resources/views/components/button/icon.blade.php
@@ -26,14 +26,8 @@ $element = match ($type) {
default => 'button',
};
-$type = match ($type) {
- 'anchor' => '',
- 'button' => 'type="button"',
- 'submit' => 'type="submit"',
- default => '',
-}
@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 }}
{{ $element }}>
diff --git a/resources/views/components/input/color-picker.blade.php b/resources/views/components/input/color-picker.blade.php
new file mode 100644
index 0000000..a050d22
--- /dev/null
+++ b/resources/views/components/input/color-picker.blade.php
@@ -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
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/components/menu/calendar-settings.blade.php b/resources/views/components/menu/calendar-settings.blade.php
index 5f48757..a63c414 100644
--- a/resources/views/components/menu/calendar-settings.blade.php
+++ b/resources/views/components/menu/calendar-settings.blade.php
@@ -31,7 +31,7 @@
@foreach ($calendars as $cal)