Improves event display on the calendar, gets closer to a v1 of the calendar design, updates guest layout to match theme, adds additional css components for accordion, calendar, checkbox, indicator, and input.

This commit is contained in:
Andrew Gioia 2025-07-25 13:49:30 -04:00
parent 643ac833ba
commit 7efcf5cf55
Signed by: andrew
GPG Key ID: FC09694A000800C8
28 changed files with 431 additions and 44 deletions

View File

@ -41,7 +41,8 @@ class CalendarController extends Controller
'calendars.id',
'ci.displayname',
'ci.calendarcolor',
'meta.color as meta_color'
'meta.color as meta_color',
'meta.color_fg as meta_color_fg'
)
->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
@ -63,12 +64,18 @@ class CalendarController extends Controller
$payload = [
'view' => $view,
'range' => $range,
'active' => [
'year' => $range['start']->format('Y'),
'month' => $range['start']->format("F"),
'day' => $range['start']->format("d"),
],
'calendars' => $calendars->keyBy('id')->map(function ($cal) {
return [
'id' => $cal->id,
'name' => $cal->displayname,
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#999',
'on' => true, // default to visible; the UI can toggle this
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', // clean this up @todo
'color_fg' => $cal->meta_color_fg ?? '#ffffff', // clean this up
'on' => true, // default to visible; the UI can toggle this
];
}),
'events' => $events->map(function ($e) { // just the events map
@ -83,7 +90,9 @@ class CalendarController extends Controller
'calendar_id' => $e->calendarid,
'title' => $e->meta->title ?? '(no title)',
'start' => $start->format('c'),
'start_ui' => $start->format('g:ia'),
'end' => optional($end)->format('c'),
'end_ui' => optional($end)->format('g:ia')
];
}),
'grid' => $grid,
@ -129,7 +138,8 @@ class CalendarController extends Controller
// update calendar meta
$instance->meta()->create([
'calendar_id' => $instanceId,
'color' => $data['color'] ?? null,
'color' => $data['color'] ?? '#1a1a1a',
'color_fg' => contrast_text_color($data['color'] ?? '#1a1a1a'),
'created_at' => now(),
'updated_at' => now(),
]);
@ -212,8 +222,9 @@ class CalendarController extends Controller
// update calendar meta (our table)
$calendar->meta()->updateOrCreate([], [
'color' => $data['color'] ?? null]
);
'color' => $data['color'] ?? '#1a1a1a',
'color_fg' => contrast_text_color($data['color'] ?? '#1a1a1a')
]);
return redirect()
->route('calendar.show', $calendar)
@ -313,7 +324,9 @@ class CalendarController extends Controller
'calendar_id' => $ev->calendarid,
'title' => $ev->meta->title ?? '(no title)',
'start' => $start->format('c'),
'end' => $end->format('c'),
'start_ui' => $start->format('g:ia'),
'end' => optional($end)->format('c'),
'end_ui' => optional($end)->format('g:ia')
];
}
}

View File

@ -15,6 +15,7 @@ class CalendarMeta extends Model
protected $fillable = [
'calendar_id',
'color',
'color_fg',
'created_at',
'edited_at',
];

67
app/Support/helpers.php Normal file
View File

@ -0,0 +1,67 @@
<?php
if (! function_exists('format_event_url')) {
/**
* format an event url with a given calendar ID and event ID
*/
function format_event_url(string $eid, string $cid): string
{
return 'calendar/'.$cid.'/event/'.$eid;
}
}
if (! function_exists('contrast_text_color')) {
/**
* Choose an accessible foreground (#fff or #000) against a HEX background.
*
* Rule set:
* 1. Calculate WCAG contrast ratios for both black and white.
* 2. Prefer the colour with the *higher* ratio.
* 3. Override: if black wins but ratio < 5.5 AND white > 4, use white.
*
* @param string $hex Background colour (3- or 6-digit hex, with or without #)
* @param string $light Return value for “white” (default '#ffffff')
* @param string $dark Return value for “black” (default '#000000')
* @return string
*/
function contrast_text_color(string $hex, string $light = '#ffffff', string $dark = '#000000'): string
{
// --- normalise ----------------------------------------------------
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) { // #abc → #aabbcc
$hex = preg_replace('/./', '$0$0', $hex);
}
[$r, $g, $b] = [
hexdec(substr($hex, 0, 2)) / 255,
hexdec(substr($hex, 2, 2)) / 255,
hexdec(substr($hex, 4, 2)) / 255,
];
// --- convert sRGB → linear RGB -----------------------------------
$linear = function (float $c): float {
return $c <= 0.04045 ? $c / 12.92 : pow(($c + 0.055) / 1.055, 2.4);
};
$R = $linear($r);
$G = $linear($g);
$B = $linear($b);
// --- relative luminance (ITU-R BT.709) ----------------------------
$L_bg = 0.2126 * $R + 0.7152 * $G + 0.0722 * $B; // 01
// --- contrast ratios vs black (L=0) and white (L=1) ---------------
$contrast_black = ($L_bg + 0.05) / 0.05; // bg lighter than black
$contrast_white = 1.05 / ($L_bg + 0.05); // white vs bg
// --- pick the winner ---------------------------------------------
$useDark = $contrast_black >= $contrast_white;
// override rule if dark is true but white "looks better"
if ($useDark && $contrast_black < 5.5 && $contrast_white > 4) {
$useDark = false; // switch to white
}
return $useDark ? $dark : $light;
}
}

View File

@ -28,7 +28,10 @@
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"files": [
"app/Support/helpers.php"
]
},
"autoload-dev": {
"psr-4": {

View File

@ -9,9 +9,10 @@ return new class extends Migration
public function up(): void
{
Schema::create('calendar_meta', function (Blueprint $table) {
$table->unsignedInteger('calendar_id')->primary(); // FK = PK
$table->string('title')->nullable(); // UI override
$table->string('color', 7)->nullable(); // e.g. #FFAA00
$table->unsignedInteger('calendar_id')->primary(); // FK = PK
$table->string('title')->nullable(); // ui override
$table->string('color', 7)->nullable(); // bg color
$table->string('color_fg', 7)->nullable(); // fg color
$table->boolean('is_shared')->default(false);
$table->json('settings')->nullable(); // arbitrary JSON
$table->timestamps();

View File

@ -5,7 +5,12 @@
/** kithkin */
@import './etc/layout.css';
@import './etc/type.css';
@import './lib/accordion.css';
@import './lib/button.css';
@import './lib/calendar.css';
@import './lib/checkbox.css';
@import './lib/indicator.css';
@import './lib/input.css';
@import './lib/mini.css';
/** plugins */

View File

@ -91,14 +91,23 @@ main {
/* main content title and actions */
> header {
@apply flex flex-row items-center justify-between px-6 2xl:px-8;
@apply grid items-center;
grid-template-columns: minmax(20rem, 20dvw) repeat(3, 1fr);
h1 {
@apply h-12 max-h-12;
@apply flex items-center pl-6 2xl:pl-8;
}
h2 {
@apply col-span-1 flex flex-row gap-1 items-center justify-start relative top-px;
> span {
@apply text-gray-700;
}
}
menu {
@apply flex flex-row items-center justify-end gap-2 h-12 max-h-12;
@apply col-span-2 flex flex-row items-center justify-end gap-2 pr-6 2xl:pr-8;
}
}
@ -125,7 +134,7 @@ main {
@media (width >= 96rem) { /* 2xl */
main {
body#app & {
grid-template-rows: 6rem auto;
grid-template-rows: 5.5rem auto;
}
}
}

View File

@ -23,8 +23,25 @@
--color-cyan-400: oklch(92.6% 0.117 195.31);
--color-cyan-500: oklch(90.54% 0.155 194.76); /* 00ffff */
--color-cyan-550: oklch(82% 0.2812 194.769); /* 00e3e3 */
--color-cyan-600: oklch(80.43% 0.137 194.76); /* todo below */
--color-cyan-700: oklch(70.28% 0.12 194.76);
--color-cyan-800: oklch(59.15% 0.101 194.76);
--color-cyan-900: oklch(49.05% 0.084 194.76);
--color-cyan-950: oklch(43.96% 0.075 194.76);
--color-magenta-50: oklch(96.93% 0.027 325.87);
--color-magenta-100: oklch(93.76% 0.056 326.06);
--color-magenta-200: oklch(87.58% 0.117 326.54);
--color-magenta-300: oklch(81.59% 0.181 327.09);
--color-magenta-400: oklch(75.66% 0.251 327.72);
--color-magenta-500: oklch(70.17% 0.322 328.37);
--color-magenta-550: oklch(0.666 0.3061 328.36); /* ee00ee */
--color-magenta-600: oklch(62.55% 0.287 328.37);
--color-magenta-700: oklch(55.14% 0.253 328.37);
--color-magenta-800: oklch(47.69% 0.219 328.37);
--color-magenta-900: oklch(40.42% 0.186 328.37);
--color-magenta-950: oklch(36.79% 0.169 328.37);
--border-width-1.5: 1.5px;
--border-width-md: 1.5px;
--radius-xs: 0.25rem;
--radius-sm: 0.375rem;
@ -36,12 +53,18 @@
--radius-4xl: 3rem;
--radius-blob: 80% 65% 90% 50% / 90% 80% 75% 75%;
--shadow-drop: 2.5px 2.5px 0 0 var(--color-primary);
--shadow-drop: 2.5px 2.5px 0 0 var(--color-primary);
--shadow-input: inset 0 0.25rem 0 0 var(--color-gray-100);
--spacing-md: 1.5px;
--spacing-2px: 2px;
--text-2xs: 0.625rem;
--text-2xs--line-height: 1.2;
--text-2xl: 1.75rem;
--text-2xl--line-height: 1.333;
--text-3xl: 2rem;
--text-3xl--line-height: calc(2.25 / 1.875);
--text-3xl--line-height: 1.2;
--text-4xl: 3rem;
--text-4xl--line-height: 1;
}

View File

@ -24,6 +24,25 @@
font-style: normal;
}
/* app name */
h1 {
@apply font-serif text-3xl font-extrabold leading-tight;
}
/* page header */
h2 {
@apply font-serif text-2xl font-extrabold leading-tight text-primary;
}
/* links */
a {
&.text {
@apply underline decoration-inherit underline-offset-2 text-magenta-600;
text-decoration-thickness: 1.5px;
transition: color 125ms ease-in-out;
&:hover {
@apply text-magenta-700;
}
}
}

View File

@ -0,0 +1,42 @@
details {
summary {
@apply relative flex items-center cursor-pointer list-none h-8 font-semibold z-0;
&::-webkit-details-marker {
@apply hidden;
}
&::before {
@apply block absolute top-0 left-0 -ml-3 -mt-1 bg-transparent rounded-md;
content: '';
height: calc(100% + 0.5rem);
transition: background-color 100ms ease-in-out;
width: calc(100% + 1.5rem);
z-index: -1;
}
&::after {
@apply block w-8 h-8 absolute right-0 top-0 bg-no-repeat bg-center;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
content: '';
transition: rotate 100ms ease-in-out;
}
&:hover {
&::before {
@apply bg-gray-100;
}
}
}
&[open] {
summary::after {
@apply rotate-180;
}
}
> .content {
@apply mt-2;
}
}

View File

@ -1,13 +1,13 @@
button,
.button {
@apply relative inline-flex items-center cursor-pointer gap-2 rounded-md h-11 px-4 text-lg font-medium;
transition: background-color 100ms ease-in-out;
transition: background-color 125ms ease-in-out;
--button-border: var(--color-primary);
--button-accent: var(--color-primary-hover);
&.button--primary {
@apply bg-cyan-300;
border: 1.5px solid var(--button-border);
@apply bg-cyan-300 border-md border-solid;
border-color: var(--button-border);
box-shadow: 2.5px 2.5px 0 0 var(--button-border);
&:hover {
@ -31,3 +31,32 @@ button,
}
}
}
.button-group {
@apply relative flex flex-row items-center p-0 m-0 h-11 max-h-11;
> label {
@apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer;
@apply border-md border-primary font-medium;
box-shadow: 1.5px 2.5px 0 0 var(--color-primary);
> input[type="radio"] {
@apply hidden absolute top-0 left-0 w-0 h-0 max-w-0 max-h-0;
}
&:first-child {
@apply rounded-l-md;
}
&:last-child {
@apply border-r-md rounded-r-md;
}
&:has(input:checked) {
@apply bg-cyan-300 border-t-2;
box-shadow: inset 0 0.25rem 0 0 var(--color-cyan-400);
left: 1.5px;
top: 2.5px;
}
}
}

View File

@ -0,0 +1,64 @@
.calendar {
@apply grid col-span-3 pr-6 2xl:pr-8 pb-6 2xl:pb-8 pt-2;
grid-template-rows: 2rem 1fr;
hgroup {
@apply grid grid-cols-7 w-full gap-1;
> span {
@apply uppercase text-right pr-4 font-bold;
}
}
ol {
@apply grid grid-cols-7 w-full gap-1;
contain: paint;
grid-auto-rows: 1fr;
li {
@apply relative px-1 pt-8 border-t-md border-primary;
&::before {
@apply absolute top-0 right-px w-auto h-8 flex items-center justify-end pr-4 text-sm font-medium;
content: attr(data-day-number);
}
&.day--outside {
@apply bg-gray-50 text-gray-700;
}
&.day--today {
@apply bg-cyan-100;
}
&:nth-child(-n+7) {
@apply border-t-2;
}
&:last-child {
@apply rounded-br-lg;
}
.event {
@apply flex items-center text-xs gap-1 px-1 py-px font-medium truncate rounded-sm bg-transparent;
transition: background-color 125ms ease-in-out;
.indicator {
--indicator-bg: var(--event-color);
}
.title {
@apply grow;
}
time {
@apply text-2xs;
}
&:hover {
background-color: color-mix(in srgb, var(--event-color) 25%, #fff 100%);
}
}
}
}
}

View File

@ -0,0 +1,16 @@
input[type="checkbox"] {
@apply border-md rounded-sm w-5 h-5 ring-0;
transition: border 150ms ease-in-out,
outline 150ms ease-in-out,
background 150ms ease-in-out,
box-shadow 200ms ease-out;
color: var(--checkbox-color);
border-color: var(--checkbox-color);
--checkbox-color: var(--color-primary);
&:focus {
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--checkbox-color), var(--tw-shadow);
}
}

View File

@ -0,0 +1,5 @@
i.indicator {
@apply inline-flex w-2.5 h-2.5 min-w-2.5 min-h-2.5 rounded-full font-normal;
background-color: var(--indicator-bg);
--indicator-bg: var(--color-magenta-500); /* default color */
}

View File

@ -0,0 +1,9 @@
input[type="email"],
input[type="text"],
input[type="password"],
input[type="search"] {
@apply border-md border-gray-800 bg-white rounded-md shadow-input;
@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;
}

View File

@ -12,10 +12,10 @@
/* days wrapper */
figure {
@apply border-1.5 border-primary shadow-drop rounded-md;
@apply border-md border-primary shadow-drop rounded-md;
/* weekdays */
figcaption {
hgroup {
@apply grid grid-cols-7 p-2 pt-3 pb-0;
span {
@ -55,7 +55,7 @@
&.day--with-events {
&::after {
@apply absolute bottom-0 left-1/2 -translate-x-1/2 h-1 rounded-full w-4 bg-yellow-500;
@apply absolute bottom-0 left-1/2 -translate-x-1/2 h-1 rounded-full w-4 bg-magenta-500;
content: '';
}
&[data-event-count='1']::after {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-left-icon lucide-chevron-left"><path d="m15 18-6-6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up-icon lucide-chevron-up"><path d="m18 15-6-6-6 6"/></svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -26,15 +26,15 @@
<!-- Remember Me -->
<div class="block mt-4">
<label for="remember_me" class="inline-flex items-center">
<input id="remember_me" type="checkbox" class="rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:ring-indigo-500" name="remember">
<span class="ms-2 text-sm text-gray-600">{{ __('Remember me') }}</span>
<label for="remember_me" class="inline-flex items-center gap-2">
<input id="remember_me" type="checkbox" name="remember">
<span>{{ __('Remember me') }}</span>
</label>
</div>
<div class="flex items-center justify-between mt-4 gap-4">
@if (Route::has('password.request'))
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('password.request') }}">
<a href="{{ route('password.request') }}" href="text">
{{ __('Forgot your password?') }}
</a>
@endif

View File

@ -3,7 +3,18 @@
<h1>
{{ __('Calendar') }}
</h1>
<h2>
<strong>{{ $active['month'] }}</strong>
<span>{{ $active['year'] }}</span>
</h2>
<menu>
<li>
<form class="button-group button-group--primary" method="get" action="/">
<x-button.group-button>Day</x-button.group-button>
<x-button.group-button>Week</x-button.group-button>
<x-button.group-button active="true">Month</x-button.group-button>
<x-button.group-button>3-Up</x-button.group-button>
</form>
<li>
<a class="button button--primary" href="{{ route('calendar.create') }}">
<x-icon-plus-circle /> Create
@ -18,19 +29,23 @@
</x-slot>
<x-slot name="article">
<aside>
<div>
@foreach ($calendars as $cal)
<label class="flex items-center space-x-2">
<input type="checkbox"
wire:model="visibleCalendars"
value="{{ $cal['id'] }}"
checked>
<span class="w-3 h-3 rounded-sm" style="background: {{ $cal['color'] }}"></span>
<span>{{ $cal['name'] }}</span>
</label>
@endforeach
<div class="flex flex-col gap-4">
<details open>
<summary>{{ __('My Calendars') }}</summary>
<ul class="content">
@foreach ($calendars as $cal)
<li>
<label class="flex items-center space-x-2">
<input type="checkbox"
value="{{ $cal['id'] }}"
style="--checkbox-color: {{ $cal['color'] }}"
checked>
<span>{{ $cal['name'] }}</span>
</label>
</li>
@endforeach
</ul>
</div>
<x-calendar.mini>
@foreach ($grid['weeks'] as $week)
@foreach ($week as $day)
@ -39,5 +54,6 @@
@endforeach
</x-calendar.mini>
</aside>
<x-calendar.full class="month" :grid="$grid" :calendars="$calendars" />
</x-slot>
</x-app-layout>

View File

@ -0,0 +1,11 @@
@props([
'type' => 'submit',
'name' => 'button-group',
'value' => '1',
'class' => '',
'active' => false ])
<label class="{{ $class }}">
<input type="radio" name="{{ $name }}" value="{{ $value }}" @checked($active)>
{{ $slot }}
</label>

View File

@ -0,0 +1,26 @@
@props([
'day', // required
'calendars' => [], // calendar palette keyed by id
])
<li
data-day-number="{{ $day['label'] }}"
data-event-count="{{ count($day['events'] ?? []) }}"
@class([
'day',
'day--with-events' => !empty($day['events']),
'day--current' => $day['in_month'],
'day--outside' => !$day['in_month'],
'day--today' => $day['is_today'],
])>
@foreach ($day['events'] as $event)
@php
$bg = $calendars[(string) $event['calendar_id']]['color'] ?? '#999';
@endphp
<a class="event" href="{{ format_event_url($event['id'], $event['calendar_id']) }}" style="--event-color: {{ $bg }}">
<i class="indicator" aria-label="Calendar indicator"></i>
<span class="title">{{ $event['title'] }}</span>
<time>{{ $event['start_ui'] }}</time>
</a>
@endforeach
</li>

View File

@ -0,0 +1,24 @@
@props([
'grid' => ['weeks' => []],
'calendars' => [],
'class' => ''
])
<section class="calendar {{ $class }}">
<hgroup>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
<span>Sun</span>
</hgroup>
<ol data-weeks="{{ count($grid['weeks']) }}">
@foreach ($grid['weeks'] as $week)
@foreach ($week as $day)
<x-calendar.day :day="$day" :calendars="$calendars" />
@endforeach
@endforeach
</ol>
</section>

View File

@ -6,7 +6,7 @@
<menu>Controls</menu>
</header>
<figure>
<figcaption>
<hgroup>
<span>U</span>
<span>M</span>
<span>T</span>
@ -14,7 +14,7 @@
<span>R</span>
<span>F</span>
<span>S</span>
</figcaption>
</hgroup>
<form action="/" method="get">
{{ $slot }}
</form>

View File

@ -1,3 +1,3 @@
@props(['disabled' => false])
<input @disabled($disabled) {{ $attributes->merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-xs']) }}>
<input @disabled($disabled) {{ $attributes->merge(['class' => '']) }}>

View File

@ -14,7 +14,7 @@
<h1>{{ config('app.name', 'Kithkin') }}</h1>
</a>
</header>
<main>
<main class="border-md border-primary shadow-drop">
{{ $slot }}
</main>
</body>