Fixes create calendar modal form, adds kithkin config settings, improvements to input and button styles, cleans up color default handling

This commit is contained in:
Andrew Gioia 2026-01-28 15:33:26 -05:00
parent 6242a9772a
commit 0b82c88333
Signed by: andrew
GPG Key ID: FC09694A000800C8
27 changed files with 491 additions and 104 deletions

View File

@ -13,6 +13,7 @@ use App\Models\CalendarInstance;
use App\Models\Event; use App\Models\Event;
use App\Models\EventMeta; use App\Models\EventMeta;
use App\Models\Subscription; use App\Models\Subscription;
use App\Services\Calendar\CreateCalendar;
class CalendarController extends Controller class CalendarController extends Controller
{ {
@ -130,6 +131,13 @@ class CalendarController extends Controller
$start_local = $start_utc->copy()->timezone($timezone); $start_local = $start_utc->copy()->timezone($timezone);
$end_local = optional($end_utc)->copy()->timezone($timezone); $end_local = optional($end_utc)->copy()->timezone($timezone);
// color handling
$color = $cal['meta_color']
?? $cal['calendarcolor']
?? default_calendar_color();
$colorFg = $cal['meta_color_fg']
?? contrast_text_color($color);
// return events array // return events array
return [ return [
'id' => $e->id, 'id' => $e->id,
@ -143,8 +151,8 @@ class CalendarController extends Controller
'end_ui' => optional($end_local)->format('g:ia'), 'end_ui' => optional($end_local)->format('g:ia'),
'timezone' => $timezone, 'timezone' => $timezone,
'visible' => $cal->visible, 'visible' => $cal->visible,
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', 'color' => $color,
'color_fg' => $cal->meta_color_fg ?? '#ffffff', 'color_fg' => $colorFg,
]; ];
})->keyBy('id'); })->keyBy('id');
@ -183,6 +191,12 @@ class CalendarController extends Controller
$tz = $cal->timezone ?? config('app.timezone'); $tz = $cal->timezone ?? config('app.timezone');
$color = $cal->meta_color
?? $cal->calendarcolor
?? default_calendar_color();
$colorFg = $cal->meta_color_fg
?? contrast_text_color($color);
return [ return [
'id' => $e->id, 'id' => $e->id,
'calendar_id' => $e->calendarid, 'calendar_id' => $e->calendarid,
@ -193,8 +207,8 @@ class CalendarController extends Controller
'end' => optional($end_utc)->toIso8601String(), 'end' => optional($end_utc)->toIso8601String(),
'timezone' => $tz, 'timezone' => $tz,
'visible' => $cal->visible, 'visible' => $cal->visible,
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', 'color' => $color,
'color_fg' => $cal->meta_color_fg ?? '#ffffff', 'color_fg' => $colorFg,
]; ];
})->keyBy('id'); })->keyBy('id');
@ -223,14 +237,22 @@ class CalendarController extends Controller
'month' => $range['start']->format("F"), 'month' => $range['start']->format("F"),
'day' => $range['start']->format("d"), 'day' => $range['start']->format("d"),
], ],
'calendars' => $calendars->mapWithKeys(function ($cal) { 'calendars' => $calendars->mapWithKeys(function ($cal)
{
// compute colors
$color = $cal->meta_color
?? $cal->calendarcolor
?? default_calendar_color();
$colorFg = $cal->meta_color_fg
?? contrast_text_color($color);
return [ return [
$cal->id => [ $cal->id => [
'id' => $cal->id, 'id' => $cal->id,
'slug' => $cal->slug, 'slug' => $cal->slug,
'name' => $cal->displayname, 'name' => $cal->displayname,
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', 'color' => $color,
'color_fg' => $cal->meta_color_fg ?? '#ffffff', 'color_fg' => $colorFg,
'visible' => $cal->visible, 'visible' => $cal->visible,
'is_remote' => $cal->is_remote, 'is_remote' => $cal->is_remote,
], ],
@ -245,50 +267,26 @@ class CalendarController extends Controller
return view('calendar.index', $payload); return view('calendar.index', $payload);
} }
public function create()
{
return view('calendar.create');
}
/** /**
* create sabre calendar + meta * create sabre calendar + meta
*/ */
public function store(Request $request) public function store(Request $request, CreateCalendar $creator)
{ {
$data = $request->validate([ $data = $request->validate([
'name' => 'required|string|max:100', 'name' => 'required|string|max:100',
'description' => 'nullable|string|max:255', 'description' => 'nullable|string|max:255',
'timezone' => 'required|string', 'timezone' => 'required|string|max:64',
'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/', 'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
'redirect' => 'nullable|string', // where to go after creating
]); ]);
// update master calendar entry $creator->create($request->user(), $data);
$calId = DB::table('calendars')->insertGetId([ $redirect = $data['redirect'] ?? route('calendar.index');
'synctoken' => 1,
'components' => 'VEVENT', // or 'VEVENT,VTODO' if you add tasks
]);
// update the calendar instance row return redirect($redirect)->with('toast', [
$instance = CalendarInstance::create([ 'message' => __('Calendar created!'),
'calendarid' => $calId, 'type' => 'success',
'principaluri' => auth()->user()->uri,
'uri' => Str::uuid(),
'displayname' => $data['name'],
'description' => $data['description'] ?? null,
'calendarcolor'=> $data['color'] ?? null,
'timezone' => $data['timezone'],
]); ]);
// update calendar meta
$instance->meta()->create([
'calendar_id' => $instanceId,
'color' => $data['color'] ?? '#1a1a1a',
'color_fg' => contrast_text_color($data['color'] ?? '#1a1a1a'),
'created_at' => now(),
'updated_at' => now(),
]);
return redirect()->route('calendar.index');
} }
/** /**
@ -365,9 +363,10 @@ class CalendarController extends Controller
$calendar->increment('synctoken'); $calendar->increment('synctoken');
// update calendar meta (our table) // update calendar meta (our table)
$color = calendar_color($data);
$calendar->meta()->updateOrCreate([], [ $calendar->meta()->updateOrCreate([], [
'color' => $data['color'] ?? '#1a1a1a', 'color' => $color,
'color_fg' => contrast_text_color($data['color'] ?? '#1a1a1a') 'color_fg' => contrast_text_color($color),
]); ]);
return redirect() return redirect()

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\CalendarInstance; use App\Models\CalendarInstance;
use App\Models\CalendarMeta; use App\Models\CalendarMeta;
use App\Models\Subscription; use App\Models\Subscription;
use App\Services\Calendar\CreateCalendar;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -102,6 +103,51 @@ class CalendarSettingsController extends Controller
->with('toast', __('Settings saved!')); ->with('toast', __('Settings saved!'));
} }
/**
* Create a local calendar
**/
public function createForm(Request $request)
{
$user = $request->user();
$data = [
'title' => __('calendar.settings.create.title'),
'sub' => __('calendar.settings.create.subtitle'),
'defaults' => [
'name' => '',
'description' => '',
'timezone' => $user->timezone ?? config('app.timezone', 'UTC'),
'color' => default_calendar_color(),
],
'redirect' => route('calendar.settings'),
];
if ($request->header('HX-Request')) {
return view('calendar.partials.create-modal', $data);
}
return $this->frame('calendar.settings.create', $data);
}
public function createStore(Request $request, CreateCalendar $creator)
{
$data = $request->validate([
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:255',
'timezone' => 'required|string',
'color' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
]);
$creator->create($request->user(), $data);
$redirect = $data['redirect'] ?? route('calendar.index');
return redirect($redirect)->with('toast', [
'message' => __('Calendar created!'),
'type' => 'success',
]);
}
/** /**
* Subscribe * Subscribe
@ -133,7 +179,7 @@ class CalendarSettingsController extends Controller
[ [
'source' => $data['source'], 'source' => $data['source'],
'displayname' => $data['displayname'] ?: $data['source'], 'displayname' => $data['displayname'] ?: $data['source'],
'calendarcolor' => $data['color'] ?? '#1a1a1a', 'calendarcolor' => calendar_color($data),
// you can add 'refreshrate' => 'P1D' here if you like // you can add 'refreshrate' => 'P1D' here if you like
] ]
); );

View File

@ -63,7 +63,7 @@ class SubscriptionController extends Controller
'principaluri' => $principalUri, 'principaluri' => $principalUri,
'source' => $source, 'source' => $source,
'displayname' => $data['displayname'] ?: $source, 'displayname' => $data['displayname'] ?: $source,
'calendarcolor' => $data['color'] ?? '#1a1a1a', 'calendarcolor' => calendar_color($data),
'refreshrate' => 'P1D', 'refreshrate' => 'P1D',
'lastmodified' => now()->timestamp, 'lastmodified' => now()->timestamp,
]); ]);
@ -133,14 +133,13 @@ class SubscriptionController extends Controller
$subscription->update($data); $subscription->update($data);
// update corresponding calendar_meta record // update corresponding calendar_meta record
$color = calendar_color(['color' => $subscription->calendarcolor]);
$subscription->meta()->updateOrCreate( $subscription->meta()->updateOrCreate(
[], // no “where” clause → look at subscription_id FK [], // no “where” clause → look at subscription_id FK
[ [
'title' => $subscription->displayname, 'title' => $subscription->displayname,
'color' => $subscription->calendarcolor ?? '#1a1a1a', 'color' => $color,
'color_fg' => contrast_text_color( 'color_fg' => contrast_text_color($color),
$subscription->calendarcolor ?? '#1a1a1a'
),
'updated_at'=> now(), 'updated_at'=> now(),
] ]
); );

View File

@ -194,6 +194,9 @@ class SyncSubscription implements ShouldQueue
'components' => 'VEVENT', 'components' => 'VEVENT',
]); ]);
// set the color
$color = calendar_color([], $meta->color);
// create the per-user instance (description is display-only; never used for lookup) // create the per-user instance (description is display-only; never used for lookup)
CalendarInstance::create([ CalendarInstance::create([
'calendarid' => $calendar->id, 'calendarid' => $calendar->id,
@ -201,7 +204,7 @@ class SyncSubscription implements ShouldQueue
'uri' => (string) Str::uuid(), 'uri' => (string) Str::uuid(),
'displayname' => $this->subscription->displayname, 'displayname' => $this->subscription->displayname,
'description' => $this->mirrorDescription($source), 'description' => $this->mirrorDescription($source),
'calendarcolor' => $meta->color ?? '#1a1a1a', 'calendarcolor' => $color,
'timezone' => config('app.timezone', 'UTC'), 'timezone' => config('app.timezone', 'UTC'),
]); ]);

View File

@ -69,10 +69,10 @@ class CalendarInstance extends Model
public function resolvedColor(?string $fallback = null): string public function resolvedColor(?string $fallback = null): string
{ {
// prefer meta color, fall back to sabre color, then default // prefer meta color, fall back to sabre color, then default
return $this->meta?->color return calendar_color(
?? $this->calendarcolor ['color' => $this->meta?->color ?? $this->calendarcolor],
?? $fallback $fallback
?? '#1a1a1a'; );
} }
public function resolvedColorFg(?string $fallback = null): string public function resolvedColorFg(?string $fallback = null): string

View File

@ -46,6 +46,8 @@ class CalendarMeta extends Model
*/ */
public static function forSubscription(Subscription $sub): self public static function forSubscription(Subscription $sub): self
{ {
$color = calendar_color([], $sub->calendarcolor);
return static::updateOrCreate( return static::updateOrCreate(
// ---- unique match-key (subscription_id is unique, nullable) ---- // ---- unique match-key (subscription_id is unique, nullable) ----
['subscription_id' => $sub->id], ['subscription_id' => $sub->id],
@ -53,8 +55,8 @@ class CalendarMeta extends Model
// ---- columns to fill / update ---- // ---- columns to fill / update ----
[ [
'title' => $sub->displayname, 'title' => $sub->displayname,
'color' => $sub->calendarcolor ?? '#1a1a1a', 'color' => $color,
'color_fg' => contrast_text_color($sub->calendarcolor ?? '#1a1a1a'), 'color_fg' => contrast_text_color($color),
'is_shared' => true, 'is_shared' => true,
'is_remote' => true, 'is_remote' => true,
// mirror_calendar_id is set later by the sync-job once the // mirror_calendar_id is set later by the sync-job once the

View File

@ -0,0 +1,51 @@
<?php
namespace App\Services\Calendar;
use App\Models\CalendarInstance;
use App\Models\CalendarMeta;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class CreateCalendar
{
public function create(User $user, array $data): CalendarInstance
{
$color = calendar_color($data);
return DB::transaction(function () use ($user, $data, $color) {
/* create master calendar container (sabre table) */
$calId = DB::table('calendars')->insertGetId([
'synctoken' => 1,
'components' => 'VEVENT',
]);
/* create per-user instance (sabre table) */
$instance = CalendarInstance::create([
'calendarid' => $calId,
'principaluri' => $user->uri, // your principal uri
'uri' => (string) Str::uuid(), // instance slug
'displayname' => $data['name'],
'description' => $data['description'] ?? null,
'calendarcolor' => $color, // keep sabre in sync
'timezone' => $data['timezone'],
]);
/* create ui meta */
CalendarMeta::updateOrCreate(
['calendar_id' => $calId], // calendar_id = sabre calendars.id (int)
[
'title' => $data['name'],
'color' => $color,
'color_fg' => contrast_text_color($color),
'is_remote' => false,
'is_shared' => false,
]
);
return $instance;
});
}
}

View File

@ -65,3 +65,89 @@ if (! function_exists('contrast_text_color')) {
return $useDark ? $dark : $light; return $useDark ? $dark : $light;
} }
} }
if (! function_exists('is_hex_color')) {
function is_hex_color(mixed $value): bool
{
return is_string($value) && preg_match('/^#[0-9A-Fa-f]{6}$/', $value) === 1;
}
}
if (! function_exists('calendar_color_palette')) {
/**
* returns a cleaned palette from config (valid hex only).
*
* @return array<int,string>
*/
function calendar_color_palette(): array
{
$palette = config('kithkin.calendar.color_palette', []);
if (! is_array($palette)) {
$palette = [];
}
return array_values(array_filter($palette, fn ($c) => is_hex_color($c)));
}
}
if (! function_exists('calendar_color_failsafe')) {
function calendar_color_failsafe(): string
{
$fallback = config('kithkin.calendar.color_failsafe', '#1a1a1a');
return is_hex_color($fallback) ? $fallback : '#1a1a1a';
}
}
if (! function_exists('default_calendar_color')) {
/**
* returns the default calendar color from the configured palette.
* if the palette is empty/invalid, falls back to the failsafe.
*/
function default_calendar_color(): string
{
$palette = calendar_color_palette();
if (count($palette) === 0) {
return calendar_color_failsafe();
}
$strategy = config('kithkin.calendar.default_color_strategy', 'first');
if ($strategy === 'random') {
return $palette[array_rand($palette)];
}
return $palette[0];
}
}
if (! function_exists('calendar_color')) {
/**
* pick a calendar color with a single source of truth for defaults.
*
* order:
* 1) $data['color'] if valid
* 2) $fallback if valid
* 3) default_calendar_color() (palette-based)
* 4) hard failsafe from config, then '#1a1a1a'
*
* @param array<string,mixed> $data
*/
function calendar_color(array $data = [], ?string $fallback = null): string
{
$raw = $data['color'] ?? null;
if (is_hex_color($raw)) {
return $raw;
}
if (is_hex_color($fallback)) {
return $fallback;
}
$default = default_calendar_color();
return is_hex_color($default) ? $default : calendar_color_failsafe();
}
}

19
config/kithkin.php Normal file
View File

@ -0,0 +1,19 @@
<?php
return [
'calendar' => [
// used by the color picker and as the default pool for calendars
'color_palette' => [
'#2563eb', '#7c3aed', '#db2777', '#ef4444', '#f97316',
'#f59e0b', '#84cc16', '#22c55e', '#14b8a6', '#06b6d4',
'#0ea5e9', '#64748b', '#d051cc', '#ffec05', '#739399',
],
// absolute failsafe if everything else is missing or invalid
'color_failsafe' => '#1a1a1a',
// how default_calendar_color() chooses from the palette
// options: 'first' | 'random'
'default_color_strategy' => 'random',
],
];

View File

@ -13,17 +13,23 @@ return [
*/ */
'color' => 'Color', 'color' => 'Color',
'create' => 'Create calendar',
'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.', 'url_help' => 'You can\'t edit a public calendar URL. If you need to make a change, unsubscribe and add it again.',
], ],
'mine' => 'My calendars',
'name' => 'Calendar name', 'name' => 'Calendar name',
'settings' => [ 'settings' => [
'calendar' => [ 'calendar' => [
'title' => 'Calendar settings', 'title' => 'Calendar settings',
'subtitle' => 'Details and settings for <strong>:calendar</strong>.' 'subtitle' => 'Details and settings for <strong>:calendar</strong>.'
], ],
'create' => [
'title' => 'Create a calendar',
'subtitle' => 'Create a new local calendar.',
],
'language_region' => [ 'language_region' => [
'title' => 'Language and region', 'title' => 'Language and region',
'subtitle' => 'Choose your default language, region, and formatting preferences. These affect how dates and times are displayed in your calendars and events.', 'subtitle' => 'Choose your default language, region, and formatting preferences. These affect how dates and times are displayed in your calendars and events.',

View File

@ -16,6 +16,7 @@ return [
'calendar' => 'Calendar', 'calendar' => 'Calendar',
'calendars' => 'Calendars', 'calendars' => 'Calendars',
'cancel' => 'Cancel', 'cancel' => 'Cancel',
'cancel_back' => 'Cancel and go back',
'cancel_funny' => 'Get me out of here', 'cancel_funny' => 'Get me out of here',
'date' => 'Date', 'date' => 'Date',
'date_select' => 'Select a date', 'date_select' => 'Select a date',

View File

@ -105,7 +105,7 @@ main {
/* if there's an aside, set the cols */ /* if there's an aside, set the cols */
&:has(aside) { &:has(aside) {
grid-template-columns: minmax(20rem, 20dvw) auto; grid-template-rows: 4rem 1fr;
} }
} }
@ -118,13 +118,10 @@ main {
/* left column */ /* left column */
aside { aside {
@apply bg-white flex flex-col col-span-1 pb-8 h-full rounded-l-lg; @apply flex flex-col col-span-1 pb-8 h-16 overflow-hidden rounded-l-lg;
@apply overflow-y-auto;
> h1 { > h1 {
@apply flex items-center h-20 min-h-20 px-6 2xl:px-8; @apply flex items-center h-16 min-h-16 px-6 2xl:px-8;
@apply backdrop-blur-xs sticky top-0 z-1 shrink-0;
background-color: rgba(255, 255, 255, 0.9);
a.app-return { a.app-return {
@apply flex flex-row gap-2 items-center; @apply flex flex-row gap-2 items-center;
@ -185,7 +182,7 @@ main {
/* main content wrapper */ /* main content wrapper */
article { article {
@apply bg-white grid grid-cols-1 w-full pl-3 2xl:pl-4 pr-6 2xl:pr-8 rounded-r-lg; @apply bg-white grid grid-cols-1 ml-2 rounded-md;
@apply overflow-y-auto; @apply overflow-y-auto;
grid-template-rows: 5rem auto; grid-template-rows: 5rem auto;
@ -309,6 +306,26 @@ main {
} }
} }
} }
main {
&:has(aside) {
grid-template-columns: minmax(20rem, 20dvw) auto;
grid-template-rows: 1fr;
}
aside {
@apply bg-white overflow-y-auto h-full;
> h1 {
@apply backdrop-blur-xs sticky top-0 z-1 shrink-0 h-20 min-h-20;
background-color: rgba(255, 255, 255, 0.9);
}
}
article {
@apply w-full ml-0 pl-3 2xl:pl-4 pr-6 2xl:pr-8 rounded-l-none rounded-r-lg;
}
}
} }
} }

View File

@ -26,12 +26,12 @@
/* app name */ /* app name */
h1 { h1 {
@apply font-serif text-3xl font-extrabold leading-tight; @apply font-serif text-2xl md:text-3xl font-extrabold leading-tight;
} }
/* page header */ /* page header */
h2 { h2 {
@apply font-serif text-2xl font-extrabold leading-tight text-primary; @apply font-serif text-xl md:text-2xl font-extrabold leading-tight text-primary;
} }
/* section dividers */ /* section dividers */

View File

@ -2,12 +2,14 @@ button,
.button { .button {
@apply relative inline-flex items-center cursor-pointer gap-2 rounded-md h-11 px-4 text-lg font-medium; @apply relative inline-flex items-center cursor-pointer gap-2 rounded-md h-11 px-4 text-lg font-medium;
/*transition: background-color 125ms ease-in-out; */ /*transition: background-color 125ms ease-in-out; */
@apply focus:outline-md focus:outline-offset-2 focus:outline-secondary; @apply focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600 focus:outline-none;
transition: border-color 125ms ease-in-out;
--button-border: var(--color-primary); --button-border: var(--color-primary);
--button-accent: var(--color-primary-hover); --button-accent: var(--color-primary-hover);
&.button--primary { &.button--primary {
@apply bg-cyan-400 border-md border-solid; @apply bg-cyan-400 border-md border-solid focus:border-primary;
border-color: var(--button-border); border-color: var(--button-border);
box-shadow: 2.5px 2.5px 0 0 var(--button-border); box-shadow: 2.5px 2.5px 0 0 var(--button-border);
@ -17,7 +19,10 @@ button,
} }
&:focus { &:focus {
@apply shadow-none; box-shadow:
2.5px 2.5px 0 0 var(--button-border),
0 0 0 2px var(--color-white),
0 0 0 4px var(--color-cyan-600);
} }
&:active { &:active {

View File

@ -74,6 +74,27 @@
} }
/* calendar list in the left bar */ /* calendar list in the left bar */
#calendar-toggles {
summary {
@apply flex items-center gap-1 justify-start;
span {
@apply capitalize;
}
a {
@apply hidden -mt-2px;
}
&:hover {
a {
@apply flex;
}
}
}
}
li.calendar-toggle { li.calendar-toggle {
@apply relative; @apply relative;

View File

@ -1,17 +1,27 @@
.colorpicker { .colorpicker {
@apply inline-flex items-center rounded-md h-11; @apply inline-flex items-center rounded-md h-11 shadow-input;
@apply border-md border-secondary shadow-input;
input[type="color"] { input[type="color"] {
@apply rounded-l-md-inset rounded-r-none h-full shrink-0 min-w-11; @apply rounded-l-md-inset rounded-r-none h-full shrink-0 min-w-11;
@apply border-md border-secondary border-r-0 outline-transparent;
transition: outline 125ms ease-in-out;
&:focus {
@apply border-primary outline-2 outline-offset-2 outline-cyan-600 z-2;
}
} }
input[type="text"] { input[type="text"] {
@apply rounded-none grow min-w-12; @apply rounded-none grow min-w-12 font-mono;
} }
button { button {
@apply w-11 min-w-11 h-full shrink-0 flex items-center justify-center rounded-l-none rounded-r-md; @apply w-11 min-w-11 h-11 min-h-11 shrink-0 flex items-center justify-center relative right-0;
@apply border-md border-secondary rounded-l-none rounded-r-md -ml-[1.5px];
@apply focus:border-primary;
transition: background-color 125ms ease-in-out,
box-shadow 125ms ease-in-out,
border-color 125ms ease-in-out;
&:hover { &:hover {
@apply bg-teal-100 shadow-input-hover; @apply bg-teal-100 shadow-input-hover;
@ -22,23 +32,3 @@
} }
} }
} }
.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;
}

View File

@ -55,6 +55,13 @@ input[type="radio"] {
@apply flex flex-row gap-2 items-center; @apply flex flex-row gap-2 items-center;
} }
/**
* textareas
*/
textarea {
@apply min-h-20;
}
/** /**
* specific minor types * specific minor types
@ -89,6 +96,14 @@ form {
&.settings { &.settings {
@apply mt-8; @apply mt-8;
@apply 2xl:max-w-3xl; @apply 2xl:max-w-3xl;
&.modal {
@apply mt-0;
.input-row {
@apply !mt-0;
}
}
} }
} }
article.settings { article.settings {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 19h6"/><path d="M16 2v4"/><path d="M19 16v6"/><path d="M21 12.598V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5"/><path d="M3 10h18"/><path d="M8 2v4"/></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house-icon lucide-house"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 363 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M12.534,22.943c-0.061,0.004 -7.534,0.057 -7.534,0.057c-1.646,0 -3,-1.354 -3,-3l0,-14c0,-1.646 1.354,-3 3,-3l2,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l6,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l2,0c1.646,0 3,1.354 3,3l0,5.738c0.018,0.068 0.03,0.139 0.033,0.212c0.027,0.551 -0.398,1.021 -0.949,1.048c-5.045,0.25 -7.682,3.879 -7.631,8.796c0.006,0.552 -0.369,1.111 -0.92,1.148Zm-5.534,-17.943l-2,0c-0.549,0 -1,0.451 -1,1l0,3l16,0l0,-3c0,-0.549 -0.451,-1 -1,-1l-2,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1l-6,0l-0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1Zm11,15l-2,0c-0.552,0 -1,-0.448 -1,-1c-0,-0.552 0.448,-1 1,-1l2,0l0,-2c0,-0.552 0.448,-1 1,-1c0.552,0 1,0.448 1,1l0,2l2,0c0.552,0 1,0.448 1,1c-0,0.552 -0.448,1 -1,1l-2,0l0,2c0,0.552 -0.448,1 -1,1c-0.552,0 -1,-0.448 -1,-1l0,-2Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M2,10c-0,-0.883 0.389,-1.722 1.058,-2.287l7,-5.999l0.005,-0.004c1.113,-0.941 2.76,-0.941 3.873,-0l0.005,0.004l6.995,5.995c0.674,0.57 1.064,1.409 1.064,2.292l0,9c0,1.646 -1.354,3 -3,3l-14,0c-1.646,0 -3,-1.354 -3,-3l0,-9Zm13.5,9.517l0,-6.517c0,-0.823 -0.677,-1.5 -1.5,-1.5l-4,0c-0.823,0 -1.5,0.677 -1.5,1.5l0,6.517l7,0Z"/></svg>

After

Width:  |  Height:  |  Size: 452 B

View File

@ -10,7 +10,15 @@
action="{{ route('calendar.index') }}" action="{{ route('calendar.index') }}"
method="get"> method="get">
<details open> <details open>
<summary>{{ __('My Calendars') }}</summary> <summary>
<span>{{ __('calendar.mine') }}</span>
<a href="{{ route('calendar.settings.create') }}"
hx-get="{{ route('calendar.settings.create') }}"
hx-target="#modal"
hx-push-url="false"
hx-swap="innerHTML"
class="button button--icon button--sm">+</a>
</summary>
<ul class="content"> <ul class="content">
@foreach ($calendars->where('is_remote', false) as $cal) @foreach ($calendars->where('is_remote', false) as $cal)
<li class="calendar-toggle"> <li class="calendar-toggle">

View File

@ -0,0 +1,51 @@
<x-modal.content>
<x-modal.title>
{{ __('calendar.settings.create.title') }}
</x-modal.title>
<x-modal.body>
<form id="create-calendar-form" method="post" action="{{ route('calendar.store') }}" class="settings modal">
@csrf
<input type="hidden" name="redirect" value="{{ $redirect ?? route('calendar.index') }}">
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="name" :value="__('Name')" />
<x-input.text id="name" name="name" type="text" :value="old('name', $defaults['name'] ?? '')" required />
<x-input.error :messages="$errors->get('name')" />
</div>
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="description" :value="__('Description')" />
<x-input.textarea id="description" name="description" type="text" :value="old('description', $defaults['description'] ?? '')" />
<x-input.error :messages="$errors->get('description')" />
</div>
</div>
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.label for="timezone" :value="__('Timezone')" />
<x-input.select id="timezone" name="timezone" :options="config('timezones')" :selected="old('timezone', $defaults['timezone'] ?? 'UTC')" />
<x-input.error :messages="$errors->get('timezone')" />
</div>
<div class="input-cell">
<x-input.label for="color" :value="__('Color')" />
<x-input.color-picker id="color" name="color" :value="old('color', $defaults['color'] ?? '#1a1a1a')" />
<x-input.error :messages="$errors->get('color')" />
</div>
</div>
</form>
</x-modal.body>
<x-modal.footer>
<x-button variant="secondary" onclick="this.closest('dialog')?.close()">
{{ __('common.cancel') }}
</x-button>
<x-button variant="primary" type="submit" form="create-calendar-form">
{{ __('calendar.create') }}
</x-button>
</x-modal.footer>
</x-modal.content>

View File

@ -0,0 +1,51 @@
<div class="description">
<p>
{{ $data['sub'] }}
</p>
</div>
<form method="post" action="{{ route('calendar.store') }}" class="settings">
@csrf
<input type="hidden" name="redirect" value="{{ $redirect ?? route('calendar.index') }}">
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="name" :value="__('Name')" />
<x-input.text id="name" name="name" type="text" :value="old('name', $defaults['name'] ?? '')" required />
<x-input.error :messages="$errors->get('name')" />
</div>
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="description" :value="__('Description')" />
<x-input.textarea id="description" name="description" type="text" :value="old('description', $defaults['description'] ?? '')" />
<x-input.error :messages="$errors->get('description')" />
</div>
</div>
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.label for="timezone" :value="__('common.timezone')" />
<x-input.select
id="timezone"
name="timezone"
placeholder="{{ __('common.timezone_select') }}"
:options="$timezones"
:selected="old('timezone', $user->timezone ?? '')"
:description="__('calendar.timezone_help')" />
<x-input.error :messages="$errors->get('timezone')" />
</div>
<div class="input-cell">
<x-input.label for="color" :value="__('Color')" />
<x-input.color-picker id="color" name="color" :value="old('color', $defaults['color'] ?? default_calendar_color())" />
<x-input.error :messages="$errors->get('color')" />
</div>
</div>
<div class="input-row input-row--actions input-row--start sticky-bottom">
<x-button variant="primary" type="submit">{{ __('calendar.create') }}</x-button>
<x-button type="anchor" variant="tertiary" href="{{ route('calendar.index') }}">{{ __('common.cancel_back') }}</x-button>
</div>
</form>

View File

@ -1,25 +1,28 @@
@props([ @props([
'id' => null, 'id' => null,
'name' => 'color', 'name' => 'color',
'value' => null, // initial hex, e.g. "#0038ff" 'value' => null,
'palette' => null, // optional override array of hex strings 'palette' => null,
'required' => false, 'required' => false,
'disabled' => false, 'disabled' => false,
]) ])
@php @php
$id = $id ?: 'color_'.Str::uuid(); $id = $id ?: 'color_'.Str::uuid();
$initial = old($name, $value) ?: '#1a1a1a';
// palette of pre-selected colors to cycle through $paletteColors = is_array($palette) && count($palette)
$pleasant = $palette ?: [ ? array_values(array_filter($palette, fn ($c) => is_hex_color($c)))
'#2563eb', '#7c3aed', '#db2777', '#ef4444', '#f97316', : calendar_color_palette();
'#f59e0b', '#84cc16', '#22c55e', '#14b8a6', '#06b6d4',
'#0ea5e9', '#64748b', '#d051cc', '#ffec05', '#739399', // initial: old() -> explicit value -> palette-based default -> failsafe
]; $initial = old($name, $value) ?: default_calendar_color();
if (! is_hex_color($initial)) {
$initial = calendar_color_failsafe();
}
@endphp @endphp
<div class="colorpicker" data-colorpicker data-palette='@json(array_values($pleasant))'> <div class="colorpicker" data-colorpicker data-palette='@json($paletteColors)'>
<input <input
id="{{ $id }}" id="{{ $id }}"
type="color" type="color"
@ -50,5 +53,4 @@
> >
<x-icon-d20 width="20" /> <x-icon-d20 width="20" />
</x-button> </x-button>
</div> </div>

View File

@ -15,6 +15,14 @@
<details open> <details open>
<summary>{{ __('Add a calendar') }}</summary> <summary>{{ __('Add a calendar') }}</summary>
<menu class="content pagelinks"> <menu class="content pagelinks">
<li>
<x-app.pagelink
href="{{ route('calendar.settings.create') }}"
:active="request()->routeIs('calendar.settings.create')"
:label="__('calendar.settings.create.title')"
icon="calendar-plus"
/>
</li>
<li> <li>
<x-app.pagelink <x-app.pagelink
href="{{ route('calendar.settings.subscribe') }}" href="{{ route('calendar.settings.subscribe') }}"

View File

@ -89,13 +89,17 @@ Route::middleware('auth')->group(function ()
Route::get('settings/subscribe', [CalendarSettingsController::class, 'subscribeForm'])->name('settings.subscribe'); Route::get('settings/subscribe', [CalendarSettingsController::class, 'subscribeForm'])->name('settings.subscribe');
Route::post('settings/subscribe', [CalendarSettingsController::class, 'subscribeStore'])->name('settings.subscribe.store'); Route::post('settings/subscribe', [CalendarSettingsController::class, 'subscribeStore'])->name('settings.subscribe.store');
// settings / create a calendar
Route::get('settings/create', [CalendarSettingsController::class, 'createForm'])->name('settings.create');
Route::post('settings/create', [CalendarSettingsController::class, 'createStore'])->name('settings.create.store');
// remote calendar subscriptions // remote calendar subscriptions
Route::resource('subscriptions', SubscriptionController::class) Route::resource('subscriptions', SubscriptionController::class)
->except(['show']); // index, create, store, edit, update, destroy ->except(['show']); // index, create, store, edit, update, destroy
// calendar settings for a specific calendar instance/container // calendar settings for a specific calendar instance/container
Route::get('settings/calendars/{calendarUri}', [CalendarSettingsController::class, 'calendarForm']) Route::get('settings/calendars/{calendarUri}', [CalendarSettingsController::class, 'calendarForm'])
->whereUuid('calendarUri') // sabre calendarid is an int ->whereUuid('calendarUri')
->name('settings.calendars.show'); ->name('settings.calendars.show');
Route::patch('settings/calendars/{calendarUri}', [CalendarSettingsController::class, 'calendarStore']) Route::patch('settings/calendars/{calendarUri}', [CalendarSettingsController::class, 'calendarStore'])
->whereUuid('calendarUri') ->whereUuid('calendarUri')