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