Adds calendar settings page and handling, adds new calendar subscription functionality, fixes big calendar object for display, improvements to object

This commit is contained in:
Andrew Gioia 2025-08-02 06:52:26 -04:00
parent e79476fa85
commit 98fb10bc14
Signed by: andrew
GPG Key ID: FC09694A000800C8
31 changed files with 736 additions and 117 deletions

View File

@ -12,6 +12,7 @@ use App\Models\CalendarMeta;
use App\Models\CalendarInstance;
use App\Models\Event;
use App\Models\EventMeta;
use App\Models\Subscription;
class CalendarController extends Controller
{
@ -44,26 +45,47 @@ class CalendarController extends Controller
// get the user's visible calendars from the left bar
$visible = collect($request->query('c', []));
// load the user's calendars
$calendars = Calendar::query()
// load the user's local calendars
$locals = Calendar::query()
->select(
'calendars.id',
'ci.displayname',
'ci.calendarcolor',
'ci.uri as slug',
'ci.timezone as timezone',
'meta.color as meta_color',
'meta.color_fg as meta_color_fg'
'ci.uri as slug',
'ci.timezone as timezone',
'meta.color as meta_color',
'meta.color_fg as meta_color_fg',
DB::raw('false as is_remote')
)
->join('calendarinstances as ci', 'ci.calendarid', '=', 'calendars.id')
->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
->where('ci.principaluri', $principal)
->orderBy('ci.displayname')
->get()
->map(function ($cal) use ($visible) {
$cal->visible = $visible->isEmpty() || $visible->contains($cal->slug);
return $cal;
});
->get();
// load the users remote/subscription calendars
$remotes = Subscription::query()
->join('calendar_meta as m', 'm.subscription_id', '=', 'calendarsubscriptions.id')
->where('principaluri', $principal)
->orderBy('displayname')
->select(
'calendarsubscriptions.id',
'calendarsubscriptions.displayname',
'calendarsubscriptions.calendarcolor',
'calendarsubscriptions.uri as slug',
DB::raw('NULL as timezone'),
'm.color as meta_color',
'm.color_fg as meta_color_fg',
DB::raw('true as is_remote')
)
->get();
// merge local and remote, and add the visibility flag
$visible = collect($request->query('c', []));
$calendars = $locals->merge($remotes)->map(function ($cal) use ($visible) {
$cal->visible = $visible->isEmpty() || $visible->contains($cal->slug);
return $cal;
});
// handy lookup: [id => calendar row]
$calendar_map = $calendars->keyBy('id');
@ -101,9 +123,22 @@ class CalendarController extends Controller
'end_ui' => optional($end_local)->format('g:ia'),
'timezone' => $timezone,
'visible' => $cal->visible,
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a',
'color_fg' => $cal->meta_color_fg ?? '#ffffff',
];
})->keyBy('id');
// create the mini calendar grid based on the mini cal controls
$mini_anchor = $request->query('mini', $range['start']->toDateString());
$mini_start = Carbon::parse($mini_anchor)->startOfMonth();
$mini_nav = [
'prev' => $mini_start->copy()->subMonth()->toDateString(),
'next' => $mini_start->copy()->addMonth()->toDateString(),
'today' => Carbon::today()->startOfMonth()->toDateString(),
'label' => $mini_start->format('F Y'),
];
$mini = $this->buildMiniGrid($mini_start, $events);
// create the calendar grid of days
$grid = $this->buildCalendarGrid($view, $range, $events);
@ -121,18 +156,23 @@ class CalendarController extends Controller
'month' => $range['start']->format("F"),
'day' => $range['start']->format("d"),
],
'calendars' => $calendar_map->map(function ($cal) {
'calendars' => $calendars->mapWithKeys(function ($cal) {
return [
'id' => $cal->id,
'slug' => $cal->slug,
'name' => $cal->displayname,
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a', // clean this up @todo
'color_fg' => $cal->meta_color_fg ?? '#ffffff', // clean this up
'visible' => true, // default to visible; the UI can toggle this
$cal->id => [
'id' => $cal->id,
'slug' => $cal->slug,
'name' => $cal->displayname,
'color' => $cal->meta_color ?? $cal->calendarcolor ?? '#1a1a1a',
'color_fg' => $cal->meta_color_fg ?? '#ffffff',
'visible' => $cal->visible,
'is_remote' => $cal->is_remote,
],
];
}),
'events' => $events, // keyed, one copy each
'grid' => $grid, // day objects hold only ID-sets
'events' => $events, // keyed, one copy each
'grid' => $grid, // day objects hold only ID-sets
'mini' => $mini, // mini calendar days with events for indicators
'mini_nav' => $mini_nav, // separate mini calendar navigation
];
return view('calendar.index', $payload);
@ -386,4 +426,54 @@ class CalendarController extends Controller
? ['days' => $days, 'weeks' => array_chunk($days, 7)]
: ['days' => $days];
}
/**
* Build the mini-month grid for day buttons
*
* Returns ['days' => [
* [
* 'date' => '2025-06-30',
* 'label' => '30',
* 'in_month' => false,
* 'events' => [id, id ]
* ],
* ]]
*/
private function buildMiniGrid(Carbon $monthStart, Collection $events): array
{
// get bounds
$monthEnd = $monthStart->copy()->endOfMonth();
$gridStart = $monthStart->copy()->startOfWeek(Carbon::MONDAY);
$gridEnd = $monthEnd->copy()->endOfWeek(Carbon::SUNDAY);
// ensure we have 42 days (6 rows); 35 = add one extra week
if ($gridStart->diffInDays($gridEnd) + 1 < 42) {
$gridEnd->addWeek();
}
/* map event-ids by yyyy-mm-dd */
$byDay = [];
foreach ($events as $ev) {
$s = Carbon::parse($ev['start']);
$e = $ev['end'] ? Carbon::parse($ev['end']) : $s;
for ($d = $s->copy()->startOfDay(); $d->lte($e); $d->addDay()) {
$byDay[$d->toDateString()][] = $ev['id'];
}
}
/* Walk the 42-day span */
$days = [];
for ($d = $gridStart->copy(); $d->lte($gridEnd); $d->addDay()) {
$iso = $d->toDateString();
$days[] = [
'date' => $iso,
'label' => $d->format('j'),
'in_month' => $d->between($monthStart, $monthEnd),
'events' => $byDay[$iso] ?? [],
];
}
// will always be 42 to ensure 6 rows
return ['days' => $days];
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
class CalendarSettingsController extends Controller
{
/* landing page list of settings choices */
public function index()
{
return $this->frame('calendar.settings.subscribe');
}
/* show “Subscribe to a calendar” form */
public function subscribeForm()
{
return $this->frame(
'calendar.settings.subscribe',
[
'title' => 'Subscribe to a calendar',
'sub' => 'Add an `.ics` calender from another service'
]);
}
/* handle POST from the subscribe form */
public function subscribeStore(Request $request)
{
$data = $request->validate([
'source' => ['required', 'url'],
'displayname' => ['nullable', 'string', 'max:100'],
'color' => ['nullable', 'regex:/^#[0-9A-F]{6}$/i'],
]);
DB::table('calendarsubscriptions')->insert([
'uri' => Str::uuid(), // local id
'principaluri' => 'principals/'.$request->user()->email,
'source' => $data['source'],
'displayname' => $data['displayname'] ?: $data['source'],
'calendarcolor' => $data['color'],
'refreshrate' => 'P1D', // daily
'lastmodified' => now()->timestamp,
]);
return redirect()
->route('calendar.settings')
->with('toast', __('Subscription added successfully!'));
}
/**
* content frame handler
*/
private function frame(?string $view = null, array $data = [])
{
return view('calendar.settings.index', [
'view' => $view,
'data' => $data,
]);
}
}

View File

@ -32,21 +32,11 @@ class SubscriptionController extends Controller
'refreshrate' => 'nullable|string|max:10',
]);
Subscription::create([
'uri' => Str::uuid(), // unique per principal
'principaluri' => auth()->user()->principal_uri,
//...$data,
'source' => $data['source'],
'displayname' => $data['displayname'] ?? null,
'calendarcolor' => $data['calendarcolor'] ?? null,
'refreshrate' => $data['refreshrate'] ?? null,
'striptodos' => false,
'stripalarms' => false,
'stripattachments' => false,
]);
Subscription::createWithMeta($request->user(), $data);
return redirect()->route('subscription.index')
->with('toast', __('Subscription added!'));
return redirect()
->route('calendar.index')
->with('toast', __('Subscription added!'));
}
public function edit(Subscription $subscription)
@ -68,8 +58,22 @@ class SubscriptionController extends Controller
'stripattachments' => 'sometimes|boolean',
]);
// update calendarsubscriptions record
$subscription->update($data);
// update corresponding calendar_meta record
$subscription->meta()->updateOrCreate(
[], // no “where” clause → look at subscription_id FK
[
'title' => $subscription->displayname,
'color' => $subscription->calendarcolor ?? '#1a1a1a',
'color_fg' => contrast_text_color(
$subscription->calendarcolor ?? '#1a1a1a'
),
'updated_at'=> now(),
]
);
return back()->with('toast', __('Subscription updated!'));
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Auth\Middleware\Authenticate as Base;
use Illuminate\Http\Request;
class HtmxAwareAuthenticate extends Base
{
protected function redirectTo(Request $request): ?string
{
// Non-HTMX: fall back to normal redirect
return $request->isMethod('get') ? route('login') : null;
}
protected function unauthenticated($request, array $guards)
{
// If it was an HTMX request, send 401 + HX-Redirect instead of 302
if ($request->header('HX-Request')) {
abort(
response('')
->header('HX-Redirect', route('login'))
->setStatusCode(401)
);
}
parent::unauthenticated($request, $guards);
}
}

View File

@ -3,11 +3,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\User;
class Subscription extends Model
{
protected $table = 'calendarsubscriptions';
public $timestamps = false; // sabre table without created_at/updated_at; may need a meta table?
/** basic table mapping */
protected $table = 'calendarsubscriptions';
public $timestamps = false; // sabre table
protected $fillable = [
'uri',
@ -22,10 +26,54 @@ class Subscription extends Model
'stripattachments',
];
/** Cast tinyint columns to booleans */
protected $casts = [
'striptodos' => 'bool',
'stripalarms' => 'bool',
'stripattachments' => 'bool',
];
/** relationship to meta row */
public function meta()
{
return $this->hasOne(\App\Models\CalendarMeta::class,
'subscription_id');
}
/**
* Create a remote calendar subscription and its UI metadata (calendar_meta).
*/
public static function createWithMeta(User $user, array $data): self
{
return DB::transaction(function () use ($user, $data) {
/** insert into calendarsubscriptions */
$sub = self::create([
'uri' => (string) Str::uuid(),
'principaluri' => $user->principal_uri,
'source' => $data['source'],
'displayname' => $data['displayname'] ?? $data['source'],
'calendarcolor' => $data['calendarcolor'] ?? null,
'refreshrate' => $data['refreshrate'] ?? 'P1D',
'calendarorder' => 0,
'striptodos' => false,
'stripalarms' => false,
'stripattachments' => false,
'lastmodified' => now()->timestamp,
]);
/** create corresponding calendar_meta row */
DB::table('calendar_meta')->insert([
'subscription_id' => $sub->id,
'is_remote' => true,
'title' => $sub->displayname,
'color' => $sub->calendarcolor ?? '#1a1a1a',
'color_fg' => contrast_text_color($sub->calendarcolor ?? '#1a1a1a'),
'is_shared' => true,
'created_at' => now(),
'updated_at' => now(),
]);
return $sub;
});
}
}

10
composer.lock generated
View File

@ -1137,16 +1137,16 @@
},
{
"name": "laravel/framework",
"version": "v12.20.0",
"version": "v12.21.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "1b9a00f8caf5503c92aa436279172beae1a484ff"
"reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/1b9a00f8caf5503c92aa436279172beae1a484ff",
"reference": "1b9a00f8caf5503c92aa436279172beae1a484ff",
"url": "https://api.github.com/repos/laravel/framework/zipball/ac8c4e73bf1b5387b709f7736d41427e6af1c93b",
"reference": "ac8c4e73bf1b5387b709f7736d41427e6af1c93b",
"shasum": ""
},
"require": {
@ -1348,7 +1348,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2025-07-08T15:02:21+00:00"
"time": "2025-07-22T15:41:55+00:00"
},
{
"name": "laravel/prompts",

View File

@ -10,20 +10,27 @@ return new class extends Migration
{
Schema::create('calendar_meta', function (Blueprint $table) {
// // FK = PK to Sabres calendars.id
$table->unsignedInteger('calendar_id')->primary(); // FK = PK
/** keys */
$table->id(); // primary key
$table->unsignedInteger('calendar_id')->nullable()->unique();
$table->unsignedInteger('subscription_id')->nullable()->unique();
// UI fields
$table->string('title')->nullable(); // ui override
$table->string('color', 7)->nullable(); // bg color
$table->string('color_fg', 7)->nullable(); // fg color
/** ui-specific fields */
$table->string('title')->nullable(); // override name
$table->string('color', 7)->nullable(); // background
$table->string('color_fg', 7)->nullable(); // foreground
$table->boolean('is_shared')->default(false);
$table->json('settings')->nullable(); // arbitrary JSON
$table->boolean('is_remote')->default(false); // local vs. subscription
$table->json('settings')->nullable(); // arbitrary JSON
$table->timestamps();
/** foreign-key constraints */
$table->foreign('calendar_id')
->references('id')
->on('calendars')
->references('id')->on('calendars')
->cascadeOnDelete();
$table->foreign('subscription_id')
->references('id')->on('calendarsubscriptions')
->cascadeOnDelete();
});
}

View File

@ -84,7 +84,8 @@ body {
* section
*/
main {
@apply rounded-lg bg-white;
@apply overflow-hidden rounded-lg;
max-height: calc(100dvh - 1rem);
/* app */
body#app & {
@ -98,37 +99,76 @@ main {
/* auth screens */
body#auth & {
@apply w-1/2 mx-auto p-8;
@apply bg-white border-md border-primary shadow-drop w-1/2 mx-auto p-8 rounded-xl;
min-width: 16rem;
max-width: 40rem;
}
/* left column */
aside {
@apply flex flex-col col-span-1 px-6 2xl:px-8 pb-8 h-full;
@apply bg-white flex flex-col col-span-1 pb-8 h-full rounded-l-lg;
@apply overflow-y-auto;
> h1 {
@apply flex items-center h-20;
@apply flex items-center h-20 min-h-20 px-6 2xl:px-8;
@apply backdrop-blur-xs sticky top-0 z-1 shrink-0;
background-color: rgba(255, 255, 255, 0.9);
}
> .aside-inset {
@apply px-6 2xl:px-8;
}
.content {
@apply grow;
}
.drawers {
@apply grow flex flex-col gap-6 pt-1;
}
.pagelinks {
@apply flex flex-col gap-2px -mx-3;
width: calc(100% + 1.5rem);
a {
@apply flex flex-row gap-2 items-center justify-start px-3 h-9 rounded-md;
transition: background-color 100ms ease-in-out;
&:hover {
@apply bg-cyan-200;
}
&.is-active {
@apply bg-cyan-400;
&:hover {
@apply bg-cyan-500;
}
}
}
}
}
/* main content wrapper */
article {
@apply grid grid-cols-1 w-full pr-6 2xl:pr-8;
@apply bg-white grid grid-cols-1 w-full pl-3 2xl:pl-4 pr-6 2xl:pr-8 rounded-r-lg;
@apply overflow-y-auto;
grid-template-rows: 5rem auto;
/* main content title and actions */
> header {
@apply flex flex-row items-center justify-between w-full;
@apply bg-white sticky top-0;
/* if h1 exists it means there's no aside, so force the width from that */
h1 {
@apply flex items-center pl-6 2xl:pl-8;
@apply relative flex items-center pl-6 2xl:pl-8;
width: minmax(20rem, 20dvw);
}
h2 {
@apply flex flex-row gap-1 items-center justify-start relative top-px;
@apply flex flex-row gap-1 items-center justify-start relative top-2px;
> span {
@apply text-gray-700;
@ -139,6 +179,27 @@ main {
@apply flex flex-row items-center justify-end gap-4;
}
}
/* sections with max width content */
&.readable {
grid-template-columns: repeat(4, 1fr);
> header {
@apply col-span-4;
}
> .content {
@apply col-span-4 2xl:col-span-3;
}
}
/* section specific */
&#calendar {
/* */
}
&#settings {
/* */
}
}
}
@media (width >= 96rem) { /* 2xl */

View File

@ -61,7 +61,9 @@
--text-2xs: 0.625rem;
--text-2xs--line-height: 1.3;
--text-xs : 0.8rem;
--text-xs: 0.8rem;
--text-md: 1.075rem;
--text-md--line-height: 1.4;
--text-2xl: 1.75rem;
--text-2xl--line-height: 1.333;
--text-3xl: 2rem;

View File

@ -46,3 +46,16 @@ a {
}
}
}
/* text */
p {
@apply text-base leading-normal;
&.big {
@apply text-lg font-medium;
}
}
.description { /* contains <p> and uses gap for spacing */
@apply space-y-3;
}

View File

@ -6,12 +6,12 @@ button,
--button-accent: var(--color-primary-hover);
&.button--primary {
@apply bg-cyan-300 border-md border-solid;
@apply bg-cyan-400 border-md border-solid;
border-color: var(--button-border);
box-shadow: 2.5px 2.5px 0 0 var(--button-border);
&:hover {
@apply bg-cyan-400;
@apply bg-cyan-500;
border-color: var(--button-accent);
}
@ -30,6 +30,10 @@ button,
background-color: rgba(0,0,0,0.075);
}
}
&.button--sm {
@apply text-base px-2 h-8;
}
}
/* button groups are used with labels/checks as well as regular buttons */
@ -49,16 +53,20 @@ button,
&:has(input:checked),
&:active {
@apply bg-cyan-300 border-r-transparent;
@apply bg-cyan-400 border-r-transparent;
box-shadow:
inset 2.5px 0 0 0 var(--color-primary),
inset 0 0.25rem 0 0 var(--color-cyan-400);
inset 0 0.25rem 0 0 var(--color-cyan-500);
top: 2.5px;
+ label,
+ button {
box-shadow: inset 1.5px 0 0 0 var(--color-primary);
}
&:hover {
@apply bg-cyan-500;
}
}
&:first-child {
@ -66,7 +74,7 @@ button,
&:has(input:checked),
&:active {
box-shadow: inset 0 0.25rem 0 0 var(--color-cyan-400);
box-shadow: inset 0 0.25rem 0 0 var(--color-cyan-500);
}
}

View File

@ -14,3 +14,7 @@ input[type="checkbox"] {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px var(--checkbox-color), var(--tw-shadow);
}
}
label.checkbox-label {
@apply flex flex-row items-center gap-2;
}

View File

@ -1,9 +1,57 @@
/**
* default text inputs
*/
input[type="email"],
input[type="text"],
input[type="password"],
input[type="text"],
input[type="url"],
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;
}
/**
* specific minor types
*/
input[type="color"] {
@apply rounded-md border-white shadow-none h-10 w-16 cursor-pointer;
@apply self-start;
&::-moz-color-swatch {
@apply border-none;
}
}
/**
* labeled text inputs
*/
label.text-label {
@apply flex flex-col gap-2;
> .label {
@apply font-semibold text-md;
}
> .description {
@apply text-gray-800;
}
}
/**
* form layouts
*/
.form-grid-1 {
@apply grid grid-cols-3 gap-6;
> label {
@apply col-span-2 col-start-1;
}
> div {
@apply col-span-3 flex flex-row justify-start gap-4 pt-4;
}
}

View File

@ -3,15 +3,19 @@
/* mini controls */
header{
@apply flex items-center justify-between px-2 pb-3;
@apply flex flex-row items-center justify-between px-2 pb-2;
> span {
@apply font-serif text-lg;
@apply font-serif text-lg grow;
}
> form {
@apply flex flex-row items-center justify-end gap-1 shrink-0 text-sm;
}
}
/* days wrapper */
figure {
nav {
@apply border-md border-primary shadow-drop rounded-md;
/* weekdays */

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-calendar-sync-icon lucide-calendar-sync"><path d="M11 10v4h4"/><path d="m11 14 1.535-1.605a5 5 0 0 1 8 1.5"/><path d="M16 2v4"/><path d="m21 18-1.535 1.605a5 5 0 0 1-8-1.5"/><path d="M21 22v-4h-4"/><path d="M21 8.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h4.3"/><path d="M3 10h4"/><path d="M8 2v4"/></svg>

After

Width:  |  Height:  |  Size: 515 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-globe-icon lucide-globe"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@ -4,25 +4,24 @@
<h1>
{{ __('Calendar') }}
</h1>
<div class="grow flex flex-col gap-4">
<details open>
<summary>{{ __('My Calendars') }}</summary>
<form id="calendar-toggles"
class="content"
<div class="content aside-inset">
<form id="calendar-toggles"
class="drawers"
action="{{ route('calendar.index') }}"
method="get">
<ul>
@foreach ($calendars as $cal)
<details open>
<summary>{{ __('My Calendars') }}</summary>
<ul class="content">
@foreach ($calendars->where('is_remote', false) as $cal)
<li>
<label class="flex items-center space-x-2">
<input type="checkbox"
class="calendar-toggle"
name="c[]"
value="{{ $cal['slug'] }}"
style="--checkbox-color: {{ $cal['color'] }}"
@checked($cal['visible'])>
<span>{{ $cal['name'] }}</span>
</label>
<x-input.checkbox-label
label="{{ $cal['name'] }}"
name="c[]"
value="{{ $cal['slug'] }}"
:checked="$cal['visible']"
checkclass="calendar-toggle"
style="--checkbox-color: {{ $cal['color'] }}"
/>
</li>
@endforeach
</ul>
@ -31,16 +30,31 @@
<noscript>
<button type="submit">{{ __('Apply') }}</button>
</noscript>
</form>
</details>
</details>
<details open>
<summary>
<span>{{ __('Other Calendars') }}</span>
<a href="{{ route('calendar.settings.subscribe') }}"
class="button button--icon button--sm">+</a>
</summary>
<ul class="content">
@foreach ($calendars->where('is_remote', true) as $cal)
<li>
<x-input.checkbox-label
label="{{ $cal['name'] }}"
name="c[]"
value="{{ $cal['slug'] }}"
:checked="$cal['visible']"
checkclass="calendar-toggle"
style="--checkbox-color: {{ $cal['color'] }}"
/>
</li>
@endforeach
</ul>
</details>
</form>
</div>
<x-calendar.mini>
@foreach ($grid['weeks'] as $week)
@foreach ($week as $day)
<x-calendar.mini-day :day="$day" />
@endforeach
@endforeach
</x-calendar.mini>
<x-calendar.mini :mini="$mini" :nav="$mini_nav" :view="$view" class="aside-inset" />
</x-slot>
<x-slot name="header">
@ -63,13 +77,13 @@
{{-- keep current view (month/week/4day) --}}
<input type="hidden" name="view" value="{{ $view }}">
<nav class="button-group button-group--primary">
<x-button.group-button type="submit" name="date" value="{{ $nav['prev'] }}">
<x-button.group-button type="submit" name="date" value="{{ $nav['prev'] }}" aria-label="Go back 1 month">
<x-icon-chevron-left />
</x-button.group-button>
<x-button.group-button type="submit" name="date" value="{{ $nav['today'] }}">
<x-button.group-button type="submit" name="date" value="{{ $nav['today'] }}" aria-label="Go to today">
Today
</x-button.group-button>
<x-button.group-button type="submit" name="date" value="{{ $nav['next'] }}">
<x-button.group-button type="submit" name="date" value="{{ $nav['next'] }}" aria-label="Go forward 1 month">
<x-icon-chevron-right />
</x-button.group-button>
</nav>
@ -91,7 +105,7 @@
</a>
</li>
<li>
<a class="button button--icon" href="{{ route('calendar.create') }}">
<a class="button button--icon" href="{{ route('calendar.settings') }}">
<x-icon-settings />
</a>
</li>

View File

@ -0,0 +1,30 @@
<x-app-layout id="settings" class="readable">
<x-slot name="aside">
<h1>
{{ __('Settings') }}
</h1>
<x-calendar.settings-menu />
</x-slot>
<x-slot name="header">
<h2>
@isset($data['title'])
{{ $data['title'] }}
@else
{{ __('Settings') }}
@endisset
</h2>
</x-slot>
<x-slot name="article">
<div class="content">
@isset($view)
@include($view, $data ?? [])
@else
<p class="text-muted">{{ __('Pick an option in the sidebar…') }}</p>
@endisset
<div class="content">
</x-slot>
</x-app-layout>

View File

@ -0,0 +1,33 @@
<div class="description">
<p>
Subscribing to a calendar adds it to your Kithkin calendar set and syncs any changes back to the original service. This means that any changes you make here will be sent to that server, and vice versa.
<p>
It's possible (and likely) that some of the things you can add to your calendar here will not work in other calendar apps or services. You'll always see that data here, though.
</p>
</div>
<form method="post"
action="{{ route('calendar.settings.subscribe.store') }}"
class="form-grid-1 mt-8">
@csrf
<x-input.text-label
name="source"
type="url"
label="{{ __('Calendar URL (ICS)') }}"
placeholder="https://ical.mac.com/ical/MoonPhases.ics"
required="true" />
<x-input.text-label
name="displayname"
type="text"
label="{{ __('Display name') }}"
placeholder="Phases of the moon..."
required="true"
description="If you leave this blank, we'll try to make a best guess for the name." />
<x-input.text-label name="color" type="color" label="{{ __('Calendar color') }}" />
<div class="flex gap-4">
<x-button variant="primary" type="submit">{{ __('Subscribe') }}</x-button>
<a href="{{ route('calendar.index') }}"
class="button button--secondary">{{ __('Cancel and go back') }}</a>
</div>
</form>

View File

@ -0,0 +1,11 @@
@props(['active'])
@php
$classes = ($active ?? false)
? 'is-active'
: '';
@endphp
<a {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</a>

View File

@ -17,11 +17,8 @@
@if(!empty($day['events']))
@foreach (array_keys($day['events']) as $eventId)
@php
/* pull the full event once */
$event = $events[$eventId];
/* calendar color */
$bg = $calendars[$event['calendar_id']]['color'] ?? '#999';
$color = $event['color'] ?? '#999';
@endphp
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.events.show', [$event['calendar_slug'], $event['id']]) }}"
@ -29,7 +26,7 @@
hx-target="#modal"
hx-push-url="false"
hx-swap="innerHTML"
style="--event-color: {{ $bg }}"
style="--event-color: {{ $color }}"
data-calendar="{{ $event['calendar_slug'] }}">
<i class="indicator" aria-label="Calendar indicator"></i>
<span class="title">{{ $event['title'] }}</span>

View File

@ -1,11 +1,31 @@
@props(['class' => ''])
@props(['mini', 'view', 'nav', 'class' => ''])
<section class="mini mini--month {{ $class }}">
<section id="mini" class="mini mini--month {{ $class }}">
<header>
<span>July 2025</span>
<menu>Controls</menu>
<span>{{ $nav['label'] }}</span>
<form
action="{{ route('calendar.index') }}"
method="get"
class="flex flex-row gap-1 items-center"
hx-get="{{ route('calendar.index') }}"
hx-target="#mini"
hx-select="#mini"
hx-swap="outerHTML"
hx-push-url="true">
{{-- preserve main calendar context for full-reload fallback --}}
<input type="hidden" name="view" value="{{ $view }}">
<input type="hidden" name="date" value="{{ request('date') }}">
{{-- nav buttons --}}
<button type="submit" name="mini" class="button--icon button--sm" value="{{ $nav['prev'] }}" aria-label="Go back 1 month">
<x-icon-chevron-left />
</button>
{{-- <!--<button type="submit" name="mini" class="button--sm" value="{{ $nav['today'] }}">Today</button>--> --}}
<button type="submit" name="mini" class="button--icon button--sm" value="{{ $nav['next'] }}" aria-label="Go forward 1 month">
<x-icon-chevron-right />
</button>
</form>
</header>
<figure>
<nav>
<hgroup>
<span>Mo</span>
<span>Tu</span>
@ -15,8 +35,32 @@
<span>Sa</span>
<span>Su</span>
</hgroup>
<form action="/" method="get">
{{ $slot }}
{{-- form drives the main calendar --}}
<form action="{{ route('calendar.index') }}"
method="get"
hx-get="{{ route('calendar.index') }}"
hx-target="#calendar"
hx-select="#calendar"
hx-swap="outerHTML"
hx-push-url="true"
hx-include="#calendar-toggles">
{{-- stay on the same view (month / week…) --}}
<input type="hidden" name="view" value="{{ $view }}">
@foreach ($mini['days'] as $day)
<button
type="submit"
name="date"
value="{{ $day['date'] }}"
data-event-count="{{ count($day['events']) }}"
class="day
{{ count($day['events']) ? 'day--with-events' : '' }}
{{ $day['in_month'] ? '' : 'day--outside' }}
{{ $day['date'] === today()->toDateString() ? 'day--today' : '' }}">
{{ $day['label'] }}
</button>
@endforeach
</form>
</figure>
</nav>
</section>

View File

@ -0,0 +1,28 @@
<div class="drawers aside-inset">
<details open>
<summary>{{ __('General settings') }}</summary>
<menu class="content pagelinks">
<li>
<x-app.pagelink
href="{{ route('calendar.settings.subscribe') }}"
:active="request()->routeIs('calendar.settings')">
<x-icon-globe width="20" />
<span>Language and region</span>
</x-app.pagelink>
</li>
</menu>
</details>
<details open>
<summary>{{ __('Add a calendar') }}</summary>
<menu class="content pagelinks">
<li>
<x-app.pagelink
href="{{ route('calendar.settings.subscribe') }}"
:active="request()->routeIs('calendar.settings.subscribe')">
<x-icon-calendar-sync width="20" />
<span>Subscribe to a calendar</span>
</x-app.pagelink>
</li>
</menu>
</details>
</div>

View File

@ -0,0 +1,14 @@
@props([
'label' => '', // label text
'labelclass' => '', // extra CSS classes for the label
'checkclass' => '', // checkbox classes
'name' => '', // checkbox name
'value' => '', // checkbox value
'style' => '', // raw style string for the checkbox
'checked' => false // true/false or truthy value
])
<label {{ $attributes->class("checkbox-label $labelclass") }}>
<x-input.checkbox :name="$name" :value="$value" :class="$checkclass" :style="$style" :checked="$checked" />
<span>{{ $label }}</span>
</label>

View File

@ -0,0 +1,14 @@
@props([
'class' => '', // extra CSS classes
'name' => '', // input name
'value' => '', // input value
'style' => '', // raw style string
'checked' => false // true/false or truthy value
])
<input type="checkbox"
name="{{ $name }}"
value="{{ $value }}"
{{ $attributes->class($class) }}
@if($style !== '') style="{{ $style }}" @endif
@checked($checked) />

View File

@ -0,0 +1,26 @@
@props([
'label' => '', // label text
'labelclass' => '', // extra CSS classes for the label
'inputclass' => '', // input classes
'name' => '', // input name
'type' => 'text', // input type (text, url, etc)
'value' => '', // input value
'placeholder' => '', // placeholder text
'style' => '', // raw style string for the input
'required' => false, // true/false or truthy value
'description' => '', // optional descriptive text below the input
])
<label {{ $attributes->class("text-label $labelclass") }}>
<span class="label">{{ $label }}</span>
<x-input.text
:type="$type"
:name="$name"
:value="$value"
:class="$inputclass"
:placeholder="$placeholder"
:style="$style"
:required="$required"
/>
@if($description !== '')<span class="description">{{ $description}}</span>@endif
</label>

View File

@ -0,0 +1,17 @@
@props([
'class' => '', // extra CSS classes
'type' => 'text', // input type
'name' => '', // input name
'value' => '', // input value
'placeholder' => '', // placeholder text
'style' => '', // raw style string
'required' => false, // true/false or truthy value
])
<input type="{{ $type }}"
name="{{ $name }}"
value="{{ $value }}"
placeholder="{{ $placeholder }}"
{{ $attributes->class($class) }}
@if($style !== '') style="{{ $style }}" @endif
@required($required) />

View File

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

View File

@ -8,15 +8,15 @@
<!-- app nav -->
<menu>
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
<x-app.nav-button :href="route('dashboard')" :active="request()->routeIs('dashboard')">
<x-icon-home class="w-7 h-7" />
</x-nav-link>
<x-nav-link :href="route('calendar.index')" :active="request()->routeIs('calendar*')">
</x-app.nav-button>
<x-app.nav-button :href="route('calendar.index')" :active="request()->routeIs('calendar*')">
<x-icon-calendar class="w-7 h-7" />
</x-nav-link>
<x-nav-link :href="route('book.index')" :active="request()->routeIs('books*')">
</x-app.nav-button>
<x-app.nav-button :href="route('book.index')" :active="request()->routeIs('books*')">
<x-icon-book-user class="w-7 h-7" />
</x-nav-link>
</x-app.nav-button>
<menu>
</section>

View File

@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\BookController;
use App\Http\Controllers\CalendarController;
use App\Http\Controllers\CalendarSettingsController;
use App\Http\Controllers\CardController;
use App\Http\Controllers\DavController;
use App\Http\Controllers\EventController;
@ -51,6 +52,14 @@ Route::middleware('auth')->group(function () {
->prefix('calendar')
->name('calendar.')
->group(function () {
// settings landing
Route::get('settings', [CalendarSettingsController::class, 'index'])->name('settings');
// settings / subscribe to a calendar
Route::get('settings/subscribe', [CalendarSettingsController::class, 'subscribeForm'])->name('settings.subscribe');
Route::post('settings/subscribe', [CalendarSettingsController::class, 'subscribeStore'])->name('settings.subscribe.store');
// remote calendar subscriptions
Route::resource('subscriptions', SubscriptionController::class)
->except(['show']); // index, create, store, edit, update, destroy