Calender month display fully handled now, modals are working, HTMX added and working, ICS and Subscription handling set up and differentiated, timezone handling to convert from UTC in the database to local finally done

This commit is contained in:
Andrew Gioia 2025-07-29 15:20:53 -04:00
parent 7efcf5cf55
commit c58a498e44
Signed by: andrew
GPG Key ID: FC09694A000800C8
48 changed files with 916 additions and 628 deletions

View File

@ -20,12 +20,12 @@ class BookController extends Controller
->select('addressbooks.*', 'meta.color', 'meta.is_default')
->get();
return view('books.index', compact('books'));
return view('book.index', compact('books'));
}
public function create()
{
return view('books.create');
return view('book.create');
}
public function store(Request $request)
@ -49,7 +49,7 @@ class BookController extends Controller
'is_default' => false,
]);
return redirect()->route('books.index');
return redirect()->route('book.index');
}
public function show(Book $book)
@ -58,14 +58,14 @@ class BookController extends Controller
$book->load('meta', 'cards');
return view('books.show', compact('book'));
return view('book.show', compact('book'));
}
public function edit(Book $book)
{
$book->load('meta');
return view('books.edit', compact('book'));
return view('book.edit', compact('book'));
}
public function update(Request $request, Book $book)
@ -83,6 +83,6 @@ class BookController extends Controller
'color' => $data['color'] ?? '#cccccc',
]);
return redirect()->route('books.index')->with('toast', 'Address Book updated.');
return redirect()->route('book.index')->with('toast', 'Address Book updated.');
}
}

View File

@ -11,6 +11,7 @@ use App\Models\Calendar;
use App\Models\CalendarMeta;
use App\Models\CalendarInstance;
use App\Models\Event;
use App\Models\EventMeta;
class CalendarController extends Controller
{
@ -35,12 +36,17 @@ class CalendarController extends Controller
// get the view and time range
[$view, $range] = $this->resolveRange($request);
// get the user's selected calendars
$visible = collect($request->query('c', []));
// load the user's calendars
$calendars = 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'
)
@ -48,19 +54,55 @@ class CalendarController extends Controller
->leftJoin('calendar_meta as meta', 'meta.calendar_id', '=', 'calendars.id')
->where('ci.principaluri', $principal)
->orderBy('ci.displayname')
->get();
->get()
->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');
// get all the events in one query
$events = Event::forCalendarsInRange(
$calendars->pluck('id'),
$range['start'],
$range['end']
);
)->map(function ($e) use ($calendar_map) {
// event's calendar
$cal = $calendar_map[$e->calendarid];
// get utc dates from the database
$start_utc = $e->meta->start_at ??
Carbon::createFromTimestamp($e->firstoccurence);
$end_utc = $e->meta->end_at ??
($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
// convert to calendar timezone
$timezone = $calendar_map[$e->calendarid]->timezone ?? config('app.timezone');
$start_local = $start_utc->copy()->timezone($timezone);
$end_local = optional($end_utc)->copy()->timezone($timezone);
// return events array
return [
'id' => $e->id,
'calendar_id' => $e->calendarid,
'calendar_slug' => $cal->slug,
'title' => $e->meta->title ?? '(no title)',
'start' => $start_utc->toIso8601String(),
'end' => optional($end_utc)->toIso8601String(),
'start_ui' => $start_local->format('g:ia'),
'end_ui' => optional($end_local)->format('g:ia'),
'timezone' => $timezone,
'visible' => $cal->visible,
];
})->keyBy('id');
// create the calendar grid of days
$grid = $this->buildCalendarGrid($view, $range, $events);
// format the data for the frontend
// format the data for the frontend, including separate arrays for events specifically and the big grid
$payload = [
'view' => $view,
'range' => $range,
@ -69,33 +111,18 @@ class CalendarController extends Controller
'month' => $range['start']->format("F"),
'day' => $range['start']->format("d"),
],
'calendars' => $calendars->keyBy('id')->map(function ($cal) {
'calendars' => $calendar_map->map(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
'on' => true, // default to visible; the UI can toggle this
'visible' => true, // default to visible; the UI can toggle this
];
}),
'events' => $events->map(function ($e) { // just the events map
// fall back to Sabre timestamps if meta is missing
$start = $e->meta->start_at
?? Carbon::createFromTimestamp($e->firstoccurence);
$end = $e->meta->end_at
?? ($e->lastoccurence ? Carbon::createFromTimestamp($e->lastoccurence) : null);
return [
'id' => $e->id,
'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,
'events' => $events, // keyed, one copy each
'grid' => $grid, // day objects hold only ID-sets
];
return view('calendar.index', $payload);
@ -283,9 +310,6 @@ class CalendarController extends Controller
return [$view, ['start' => $start, 'end' => $end]];
}
/**
* Assemble an array of day-objects for the requested view.
*
@ -303,71 +327,53 @@ class CalendarController extends Controller
private function buildCalendarGrid(string $view, array $range, Collection $events): array
{
// index events by YYYY-MM-DD for quick lookup */
$eventsByDay = [];
$events_by_day = [];
foreach ($events as $ev) {
$start = $ev->meta->start_at
?? Carbon::createFromTimestamp($ev->firstoccurence);
$end = $ev->meta->end_at
?? ($ev->lastoccurence
? Carbon::createFromTimestamp($ev->lastoccurence)
: $start);
$start = Carbon::parse($ev['start'])->tz($ev['timezone']);
$end = $ev['end'] ? Carbon::parse($ev['end'])->tz($ev['timezone']) : $start;
// spread multi-day events across each day they touch
// spread multi-day events
for ($d = $start->copy()->startOfDay();
$d->lte($end->copy()->endOfDay());
$d->addDay()) {
$key = $d->toDateString(); // e.g. '2025-07-14'
$eventsByDay[$key] ??= [];
$eventsByDay[$key][] = [
'id' => $ev->id,
'calendar_id' => $ev->calendarid,
'title' => $ev->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')
];
$key = $d->toDateString();
$events_by_day[$key][] = $ev['id'];
}
}
// determine which individual days belong to this view */
// determine span of days for the selected view
switch ($view) {
case 'week':
$gridStart = $range['start']->copy();
$gridEnd = $range['start']->copy()->addDays(6);
$grid_start = $range['start']->copy();
$grid_end = $range['start']->copy()->addDays(6);
break;
case '4day':
$gridStart = $range['start']->copy();
$gridEnd = $range['start']->copy()->addDays(3);
$grid_start = $range['start']->copy();
$grid_end = $range['start']->copy()->addDays(3);
break;
default: // month
$gridStart = $range['start']->copy()->startOfWeek(); // Sunday-start; tweak if needed
$gridEnd = $range['end']->copy()->endOfWeek();
default: /* month */
$grid_start = $range['start']->copy()->startOfWeek(); // Sunday start
$grid_end = $range['end']->copy()->endOfWeek();
}
// walk the span, build the day objects */
// view span bounds and build day objects
$days = [];
for ($day = $gridStart->copy(); $day->lte($gridEnd); $day->addDay()) {
for ($day = $grid_start->copy(); $day->lte($grid_end); $day->addDay()) {
$iso = $day->toDateString();
$isToday = $day->isSameDay(Carbon::today());
$days[] = [
'date' => $iso,
'label' => $day->format('j'),
'in_month' => $day->month === $range['start']->month,
'is_today' => $isToday,
'events' => $eventsByDay[$iso] ?? [],
'is_today' => $day->isSameDay(Carbon::today()),
'events' => array_fill_keys($events_by_day[$iso] ?? [], true),
];
}
// for a month view, also group into weeks
if ($view === 'month') {
$weeks = array_chunk($days, 7); // 7 days per week row
return ['days' => $days, 'weeks' => $weeks];
}
return ['days' => $days];
return $view === 'month'
? ['days' => $days, 'weeks' => array_chunk($days, 7)]
: ['days' => $days];
}
}

View File

@ -16,6 +16,7 @@ use Sabre\DAVACL\PrincipalCollection;
use Sabre\CalDAV\CalendarRoot;
use Sabre\CalDAV\Plugin as CalDavPlugin;
use Sabre\CalDAV\Backend\PDO as CalDAVPDO;
use Sabre\CalDAV\Subscriptions\Plugin as SubscriptionsPlugin;
use Sabre\CardDAV\AddressBookRoot;
use Sabre\CardDAV\Plugin as CardDavPlugin;
use Sabre\CardDAV\Backend\PDO as CardDAVPDO;
@ -49,6 +50,7 @@ class DavController extends Controller
$server->addPlugin(new ACLPlugin());
$server->addPlugin(new CalDavPlugin());
$server->addPlugin(new CardDavPlugin());
$server->addPlugin(new SubscriptionsPlugin());
$server->on('beforeMethod', function () {
\Log::info('SabreDAV beforeMethod triggered');

View File

@ -33,9 +33,72 @@ class EventController extends Controller
$start = $event->start_at;
$end = $event->end_at;
return view('events.form', compact('calendar', 'instance', 'event', 'start', 'end'));
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end'));
}
/**
* edit event page
*/
public function edit(Calendar $calendar, Event $event)
{
$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
if ((int) $event->calendarid !== (int) $calendar->id) {
abort(Response::HTTP_NOT_FOUND);
}
// authorize
$this->authorize('view', $event);
// eager-load meta so the view has everything
$event->load('meta');
// check for HTML; it sends `HX-Request: true` on every AJAX call
$isHtmx = $request->header('HX-Request') === 'true';
// 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);
$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
}
/**
* BACKEND METHODS
*
*/
/**
* insert vevent into sabres calendarobjects + meta row
*/
@ -108,28 +171,7 @@ ICS;
'end_at' => $end,
]);
return redirect()->route('calendars.show', $calendar);
}
/**
* show the event edit form
*/
public function edit(Calendar $calendar, Event $event)
{
$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('events.form', compact('calendar', 'instance', 'event', 'start', 'end'));
return redirect()->route('calendar.show', $calendar);
}
/**
@ -196,6 +238,6 @@ ICS;
'end_at' => $end,
]);
return redirect()->route('calendars.show', $calendar);
return redirect()->route('calendar.show', $calendar);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\CalendarInstance;
use Illuminate\Support\Facades\Response;
use Carbon\Carbon;
class IcsController extends Controller
{
public function download(string $calendarUri)
{
$instance = CalendarInstance::where('uri', $calendarUri)->firstOrFail();
$calendar = $instance->calendar()->with(['events.meta'])->firstOrFail();
$timezone = $instance->timezone ?? 'UTC';
$ical = $this->generateICalendarFeed($calendar->events, $timezone);
return Response::make($ical, 200, [
'Content-Type' => 'text/calendar; charset=utf-8',
'Content-Disposition' => 'inline; filename="' . $calendarUri . '.ics"',
]);
}
protected function generateICalendarFeed($events, string $tz): string
{
$output = [];
$output[] = 'BEGIN:VCALENDAR';
$output[] = 'VERSION:2.0';
$output[] = 'PRODID:-//Kithkin Calendar//EN';
$output[] = 'CALSCALE:GREGORIAN';
$output[] = 'METHOD:PUBLISH';
foreach ($events as $event) {
$meta = $event->meta;
if (!$meta || !$meta->start_at || !$meta->end_at) {
continue;
}
$start = Carbon::parse($meta->start_at)->timezone($tz)->format('Ymd\THis');
$end = Carbon::parse($meta->end_at)->timezone($tz)->format('Ymd\THis');
$output[] = 'BEGIN:VEVENT';
$output[] = 'UID:' . $event->uid;
$output[] = 'SUMMARY:' . $this->escape($meta->title ?? '(Untitled)');
$output[] = 'DESCRIPTION:' . $this->escape($meta->description ?? '');
$output[] = 'DTSTART;TZID=' . $tz . ':' . $start;
$output[] = 'DTEND;TZID=' . $tz . ':' . $end;
$output[] = 'DTSTAMP:' . Carbon::parse($event->lastmodified)->format('Ymd\THis\Z');
if ($meta->location) {
$output[] = 'LOCATION:' . $this->escape($meta->location);
}
$output[] = 'END:VEVENT';
}
$output[] = 'END:VCALENDAR';
return implode("\r\n", $output);
}
protected function escape(?string $text): string
{
return str_replace(['\\', ';', ',', "\n"], ['\\\\', '\;', '\,', '\n'], $text ?? '');
}
}

View File

@ -2,66 +2,82 @@
namespace App\Http\Controllers;
use App\Models\CalendarInstance;
use Illuminate\Support\Facades\Response;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Models\Subscription;
class SubscriptionController extends Controller
{
public function download(string $calendarUri)
public function index()
{
$instance = CalendarInstance::where('uri', $calendarUri)->firstOrFail();
$subs = Subscription::where(
'principaluri',
auth()->user()->principal_uri
)->get();
$calendar = $instance->calendar()->with(['events.meta'])->firstOrFail();
$timezone = $instance->timezone ?? 'UTC';
return view('subscription.index', compact('subs'));
}
$ical = $this->generateICalendarFeed($calendar->events, $timezone);
public function create()
{
return view('subscription.create');
}
return Response::make($ical, 200, [
'Content-Type' => 'text/calendar; charset=utf-8',
'Content-Disposition' => 'inline; filename="' . $calendarUri . '.ics"',
public function store(Request $request)
{
$data = $request->validate([
'source' => 'required|url',
'displayname' => 'nullable|string|max:255',
'calendarcolor' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
'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,
]);
return redirect()->route('subscription.index')
->with('toast', __('Subscription added!'));
}
protected function generateICalendarFeed($events, string $tz): string
public function edit(Subscription $subscription)
{
$output = [];
$output[] = 'BEGIN:VCALENDAR';
$output[] = 'VERSION:2.0';
$output[] = 'PRODID:-//Kithkin Calendar//EN';
$output[] = 'CALSCALE:GREGORIAN';
$output[] = 'METHOD:PUBLISH';
foreach ($events as $event) {
$meta = $event->meta;
if (!$meta || !$meta->start_at || !$meta->end_at) {
continue;
$this->authorize('update', $subscription);
return view('subscription.edit', ['subscription' => $subscription]);
}
$start = Carbon::parse($meta->start_at)->timezone($tz)->format('Ymd\THis');
$end = Carbon::parse($meta->end_at)->timezone($tz)->format('Ymd\THis');
$output[] = 'BEGIN:VEVENT';
$output[] = 'UID:' . $event->uid;
$output[] = 'SUMMARY:' . $this->escape($meta->title ?? '(Untitled)');
$output[] = 'DESCRIPTION:' . $this->escape($meta->description ?? '');
$output[] = 'DTSTART;TZID=' . $tz . ':' . $start;
$output[] = 'DTEND;TZID=' . $tz . ':' . $end;
$output[] = 'DTSTAMP:' . Carbon::parse($event->lastmodified)->format('Ymd\THis\Z');
if ($meta->location) {
$output[] = 'LOCATION:' . $this->escape($meta->location);
}
$output[] = 'END:VEVENT';
}
$output[] = 'END:VCALENDAR';
return implode("\r\n", $output);
}
protected function escape(?string $text): string
public function update(Request $request, Subscription $subscription)
{
return str_replace(['\\', ';', ',', "\n"], ['\\\\', '\;', '\,', '\n'], $text ?? '');
$this->authorize('update', $subscription);
$data = $request->validate([
'displayname' => 'nullable|string|max:255',
'calendarcolor' => 'nullable|regex:/^#[0-9A-Fa-f]{6}$/',
'refreshrate' => 'nullable|string|max:10',
'striptodos' => 'sometimes|boolean',
'stripalarms' => 'sometimes|boolean',
'stripattachments' => 'sometimes|boolean',
]);
$subscription->update($data);
return back()->with('toast', __('Subscription updated!'));
}
public function destroy(Subscription $subscription)
{
$this->authorize('delete', $subscription);
$subscription->delete();
return back()->with('toast', __('Subscription removed!'));
}
}

View File

@ -2,9 +2,11 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Auth;
class Calendar extends Model
{
@ -44,4 +46,20 @@ class Calendar extends Model
->where('principaluri', 'principals/' . $user->email)
->first();
}
/**
* convert "/calendar/{slug}" into the correct calendar instance (uri column)
*
* @param mixed $value The URI segment (instance UUID).
* @param string|null $field Ignored in our override.
*/
public function resolveRouteBinding($value, $field = null): mixed
{
return $this->whereHas('instances', function (Builder $q) use ($value) {
$q->where('uri', $value)
->where('principaluri', Auth::user()->principal_uri);
})
->with('instances')
->firstOrFail();
}
}

View File

@ -24,6 +24,12 @@ class CalendarMeta extends Model
'settings' => 'array',
];
protected static function boot()
{
parent::boot();
//
}
public function calendar(): BelongsTo
{
return $this->belongsTo(Calendar::class, 'calendar_id');

View File

@ -5,7 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContactMeta extends Model
class CardMeta extends Model
{
protected $table = 'contact_meta';
protected $primaryKey = 'card_id';

View File

@ -24,12 +24,6 @@ class Event extends Model
'calendardata',
];
/* casts */
protected $casts = [
'start_at' => 'datetime',
'end_at' => 'datetime',
];
/* owning calendar */
public function calendar(): BelongsTo
{

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Subscription extends Model
{
protected $table = 'calendarsubscriptions';
public $timestamps = false; // sabre table without created_at/updated_at; may need a meta table?
protected $fillable = [
'uri',
'principaluri',
'source',
'displayname',
'calendarcolor',
'refreshrate',
'calendarorder',
'striptodos',
'stripalarms',
'stripattachments',
];
/** Cast tinyint columns to booleans */
protected $casts = [
'striptodos' => 'bool',
'stripalarms' => 'bool',
'stripattachments' => 'bool',
];
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Event;
use App\Models\Calendar;
class EventPolicy
{
/* -------------------------------------------------
| Helper: does the user own the calendar?
|-------------------------------------------------*/
private function ownsCalendar(User $user, Calendar $calendar): bool
{
return $calendar->instances()
->where('principaluri', 'principals/'.$user->email)
->exists();
}
/* -------------------------------------------------
| List all events (e.g. /calendar/{id}/events)
|-------------------------------------------------*/
public function viewAny(User $user): bool
{
return true; // authenticated users can query their events
}
/* -------------------------------------------------
| Show a single event (/calendar/{id}/event/{event})
|-------------------------------------------------*/
public function view(User $user, Event $event): bool
{
return $this->ownsCalendar($user, $event->calendar);
}
/* -------------------------------------------------
| Create an event (needs parent calendar)
|-------------------------------------------------*/
public function create(User $user, Calendar $calendar): bool
{
return $this->ownsCalendar($user, $calendar);
}
/* -------------------------------------------------
| Update / delete use same ownership rule
|-------------------------------------------------*/
public function update(User $user, Event $event): bool
{
return $this->view($user, $event);
}
public function delete(User $user, Event $event): bool
{
return $this->view($user, $event);
}
/* -------------------------------------------------
| Not supported
|-------------------------------------------------*/
public function restore(User $user, Event $event): bool
{
return false;
}
public function forceDelete(User $user, Event $event): bool
{
return false;
}
}

View File

@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Blade;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
* register any application services
*/
public function register(): void
{
@ -16,7 +16,7 @@ class AppServiceProvider extends ServiceProvider
}
/**
* Bootstrap any application services.
* bootstrap any application services
*/
public function boot(): void
{

View File

@ -9,7 +9,11 @@ return new class extends Migration
public function up(): void
{
Schema::create('calendar_meta', function (Blueprint $table) {
// // FK = PK to Sabres calendars.id
$table->unsignedInteger('calendar_id')->primary(); // FK = PK
// UI fields
$table->string('title')->nullable(); // ui override
$table->string('color', 7)->nullable(); // bg color
$table->string('color_fg', 7)->nullable(); // fg color

View File

@ -13,14 +13,19 @@ class DatabaseSeeder extends Seeder
{
public function run(): void
{
/** credentials from .env (with sensible fall-backs) */
/**
*
* admin users
*/
// credentials from .env (with sensible fall-backs)
$email = env('ADMIN_EMAIL', 'admin@example.com');
$password = env('ADMIN_PASSWORD', 'changeme');
$firstname = env('ADMIN_FIRSTNAME', 'Admin');
$lastname = env('ADMIN_LASTNAME', 'Account');
$timezone = env('APP_TIMEZONE', 'UTC');
/** create or update the admin user */
// create or update the admin user
$user = User::updateOrCreate(
['email' => $email],
[
@ -31,13 +36,18 @@ class DatabaseSeeder extends Seeder
]
);
/** fill the sabre-friendly columns */
// fill the sabre-friendly columns
$user->update([
'uri' => 'principals/'.$user->email,
'displayname' => $firstname.' '.$lastname,
]);
/** sample caldav data */
/**
*
* calendar and meta
*/
// sample caldav data
$calId = DB::table('calendars')->insertGetId([
'synctoken' => 1,
'components' => 'VEVENT',
@ -50,19 +60,30 @@ class DatabaseSeeder extends Seeder
'displayname' => 'Sample Calendar',
'description' => 'Seeded calendar',
'calendarorder' => 0,
'calendarcolor' => '#007db6',
'timezone' => config('app.timezone', 'UTC'),
'calendarcolor' => '#0038ff',
'timezone' => $timezone,
]);
DB::table('calendar_meta')->updateOrInsert(
['calendar_id' => $instanceId],
['color' => '#007AFF']
['calendar_id' => $calId], // @todo should this be calendar id or instance id?
['color' => '#0038ff']
);
/** sample vevent */
/**
*
* create helper function for events to be added
**/
$insertEvent = function (Carbon $start, string $summary) use ($calId) {
// set base vars
$uid = Str::uuid().'@kithkin.lan';
$start = Carbon::now();
$end = Carbon::now()->addHour();
$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');
$ical = <<<ICS
BEGIN:VCALENDAR
@ -70,10 +91,10 @@ VERSION:2.0
PRODID:-//Kithkin//Laravel CalDAV//EN
BEGIN:VEVENT
UID:$uid
DTSTAMP:{$start->utc()->format('Ymd\\THis\\Z')}
DTSTART:{$start->format('Ymd\\THis')}
DTEND:{$end->format('Ymd\\THis')}
SUMMARY:Seed Event
DTSTAMP:$dtstamp
DTSTART:$dtstart
DTEND:$dtend
SUMMARY:$summary
DESCRIPTION:Automatically seeded event
LOCATION:Home Office
END:VEVENT
@ -94,19 +115,52 @@ ICS;
DB::table('event_meta')->updateOrInsert(
['event_id' => $eventId],
[
'title' => 'Seed Event',
'title' => $summary,
'description' => 'Automatically seeded event',
'location' => 'Home Office',
'all_day' => false,
'category' => 'Demo',
'start_at' => $start,
'end_at' => $end,
'start_at' => $start->copy()->utc(),
'end_at' => $end->copy()->utc(),
'created_at' => now(),
'updated_at' => now(),
]
);
};
/** create cards */
/**
*
* create events
*/
$now = Carbon::now()->setSeconds(0);
// 3 events today
$insertEvent($now->copy(), 'Playground with James');
$insertEvent($now->copy()->addHours(2), 'Lunch with Daniel');
$insertEvent($now->copy()->addHours(4), 'Baseball practice');
// 1 event 3 days ago
$past = $now->copy()->subDays(3)->setTime(10, 0);
$insertEvent($past, 'Kids doctors appointments');
// 1 event 2 days ahead
$future2 = $now->copy()->addDays(2)->setTime(14, 0);
$insertEvent($future2, 'Teacher conference (Nuthatches)');
// 2 events 5 days ahead
$future5a = $now->copy()->addDays(5)->setTime(9, 0);
$future5b = $future5a->copy()->addHours(2);
$insertEvent($future5a, 'Teacher conference (3rd grade)');
$insertEvent($future5b, 'Family game night');
/**
*
* address books
*
*/
// create cards
$bookId = DB::table('addressbooks')->insertGetId([
'principaluri' => $user->uri,
'uri' => 'default',
@ -114,14 +168,14 @@ ICS;
]);
$vcard = <<<VCF
BEGIN:VCARD
VERSION:3.0
FN:Seeded Contact
EMAIL:seeded@example.com
TEL:+1-555-123-4567
UID:seeded-contact-001
END:VCARD
VCF;
BEGIN:VCARD
VERSION:3.0
FN:Seeded Contact
EMAIL:seeded@example.com
TEL:+1-555-123-4567
UID:seeded-contact-001
END:VCARD
VCF;
DB::table('addressbook_meta')->insert([
'addressbook_id' => $bookId,

15
package-lock.json generated
View File

@ -4,12 +4,14 @@
"requires": true,
"packages": {
"": {
"name": "kithkin",
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"axios": "^1.8.2",
"concurrently": "^9.0.1",
"htmx.org": "^2.0.6",
"laravel-vite-plugin": "^1.2.0",
"postcss": "^8.4.31",
"tailwindcss": "^4.1.11",
@ -1526,9 +1528,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1688,6 +1690,13 @@
"node": ">= 0.4"
}
},
"node_modules/htmx.org": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
"integrity": "sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA==",
"dev": true,
"license": "0BSD"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",

View File

@ -12,6 +12,7 @@
"@tailwindcss/vite": "^4.1.11",
"axios": "^1.8.2",
"concurrently": "^9.0.1",
"htmx.org": "^2.0.6",
"laravel-vite-plugin": "^1.2.0",
"postcss": "^8.4.31",
"tailwindcss": "^4.1.11",

View File

@ -12,6 +12,7 @@
@import './lib/indicator.css';
@import './lib/input.css';
@import './lib/mini.css';
@import './lib/modal.css';
/** plugins */
@plugin '@tailwindcss/forms';

View File

@ -60,7 +60,8 @@
--spacing-2px: 2px;
--text-2xs: 0.625rem;
--text-2xs--line-height: 1.2;
--text-2xs--line-height: 1.3;
--text-xs : 0.8rem;
--text-2xl: 1.75rem;
--text-2xl--line-height: 1.333;
--text-3xl: 2rem;

View File

@ -16,7 +16,7 @@
grid-auto-rows: 1fr;
li {
@apply relative px-1 pt-8 border-t-md border-primary;
@apply relative px-1 pt-8 border-t-md border-gray-900;
&::before {
@apply absolute top-0 right-px w-auto h-8 flex items-center justify-end pr-4 text-sm font-medium;
@ -48,16 +48,20 @@
}
.title {
@apply grow;
@apply grow truncate;
}
time {
@apply text-2xs;
@apply text-2xs shrink-0 mt-px;
}
&:hover {
background-color: color-mix(in srgb, var(--event-color) 25%, #fff 100%);
}
&.hidden {
@apply hidden;
}
}
}
}

View File

@ -0,0 +1,53 @@
dialog {
@apply grid fixed top-0 right-0 bottom-0 left-0 m-0 p-0 pointer-events-none;
@apply justify-items-center items-start bg-transparent opacity-0 invisible;
@apply w-full h-full max-w-full max-h-full overflow-y-hidden;
background-color: rgba(26, 26, 26, 0.75);
backdrop-filter: blur(0.25rem);
grid-template-rows: minmax(20dvh, 2rem) 1fr;
transition:
opacity 150ms cubic-bezier(0,0,.2,1),
visibility 150ms cubic-bezier(0,0,.2,1);
z-index: 100;
#modal {
@apply relative rounded-lg bg-white border-gray-200 p-0;
@apply flex flex-col items-start col-start-1 row-start-2 translate-y-4;
@apply overscroll-contain overflow-y-auto;
max-height: calc(100vh - 5em);
width: 91.666667%;
max-width: 36rem;
transition: all 150ms cubic-bezier(0,0,.2,1);
box-shadow: #00000040 0 1.5rem 4rem -0.5rem;
> form {
@apply absolute top-4 right-4;
}
> .content {
@apply w-full;
/* modal header */
h2 {
@apply pr-12;
}
}
}
&::backdrop {
display: none;
}
&[open] {
@apply opacity-100 visible;
pointer-events: inherit;
#modal {
@apply translate-y-0;
}
&::backdrop {
@apply opacity-100;
}
}
}

View File

@ -1 +1,26 @@
import './bootstrap';
import htmx from 'htmx.org';
// make html globally visible to use the devtools and extensions
window.htmx = htmx;
// global htmx config
htmx.config.historyEnabled = true; // HX-Boost back/forward support
htmx.logger = console.log; // verbose logging during dev
// calendar toggle
// * progressive enhancement on html form with no js
document.addEventListener('change', event => {
const checkbox = event.target;
// ignore anything that isnt one of our checkboxes
if (!checkbox.matches('.calendar-toggle')) return;
const slug = checkbox.value;
const show = checkbox.checked;
// toggle .hidden on every matching event element
document
.querySelectorAll(`[data-calendar="${slug}"]`)
.forEach(el => el.classList.toggle('hidden', !show));
});

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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -0,0 +1,4 @@
<x-layout>
<h1>Create Address Book</h1>
@include('addressbooks.form', ['action' => route('book.store'), 'isEdit' => false])
</x-layout>

View File

@ -0,0 +1,4 @@
<x-layout>
<h1>Edit Address Book</h1>
@include('addressbooks.form', ['action' => route('book.update', $addressbook), 'isEdit' => true])
</x-layout>

View File

@ -1,7 +1,7 @@
<x-app-layout>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight">
{{ __('My Address Books') }}
{{ __('Contacts') }}
</h2>
</x-slot>
@ -13,7 +13,7 @@
<ul class="divide-y divide-gray-200">
@forelse($books as $book)
<li class="px-6 py-4 flex items-center justify-between">
<a href="{{ route('books.show', $book) }}" class="font-medium text-indigo-600">
<a href="{{ route('book.show', $book) }}" class="font-medium text-indigo-600">
{{ $book->displayname }}
</a>
</li>

View File

@ -10,6 +10,6 @@
@endforeach
</ul>
<a href="{{ route('books.edit', $book) }}">Edit Book</a>
<a href="{{ route('books.index') }}">Back to all</a>
<a href="{{ route('book.edit', $book) }}">Edit Book</a>
<a href="{{ route('book.index') }}">Back to all</a>
</x-app-layout>

View File

@ -1,4 +0,0 @@
<x-layout>
<h1>Create Address Book</h1>
@include('addressbooks.form', ['action' => route('addressbooks.store'), 'isEdit' => false])
</x-layout>

View File

@ -1,4 +0,0 @@
<x-layout>
<h1>Edit Address Book</h1>
@include('addressbooks.form', ['action' => route('addressbooks.update', $addressbook), 'isEdit' => true])
</x-layout>

View File

@ -1,4 +1,5 @@
<x-app-layout id="calendar">
<x-slot name="header">
<h1>
{{ __('Calendar') }}
@ -27,24 +28,39 @@
</li>
</menu>
</x-slot>
<x-slot name="article">
<aside>
<div class="flex flex-col gap-4">
<details open>
<summary>{{ __('My Calendars') }}</summary>
<ul class="content">
<form id="calendar-toggles"
class="content"
action="{{ route('calendar.index') }}"
method="get">
<ul>
@foreach ($calendars as $cal)
<li>
<label class="flex items-center space-x-2">
<input type="checkbox"
value="{{ $cal['id'] }}"
class="calendar-toggle"
name="c[]"
value="{{ $cal['slug'] }}"
style="--checkbox-color: {{ $cal['color'] }}"
checked>
@checked($cal['visible'])>
<span>{{ $cal['name'] }}</span>
</label>
</li>
@endforeach
</ul>
{{-- fallback submit button for no-JS environments --}}
<noscript>
<button type="submit">{{ __('Apply') }}</button>
</noscript>
</form>
</details>
</div>
<x-calendar.mini>
@foreach ($grid['weeks'] as $week)
@ -54,6 +70,8 @@
@endforeach
</x-calendar.mini>
</aside>
<x-calendar.full class="month" :grid="$grid" :calendars="$calendars" />
<x-calendar.full class="month" :grid="$grid" :calendars="$calendars" :events="$events" />
</x-slot>
</x-app-layout>

View File

@ -1,5 +1,6 @@
@props([
'day', // required
'events',
'calendars' => [], // calendar palette keyed by id
])
@ -13,14 +14,27 @@
'day--outside' => !$day['in_month'],
'day--today' => $day['is_today'],
])>
@foreach ($day['events'] as $event)
@if(!empty($day['events']))
@foreach (array_keys($day['events']) as $eventId)
@php
$bg = $calendars[(string) $event['calendar_id']]['color'] ?? '#999';
/* pull the full event once */
$event = $events[$eventId];
/* calendar color */
$bg = $calendars[$event['calendar_id']]['color'] ?? '#999';
@endphp
<a class="event" href="{{ format_event_url($event['id'], $event['calendar_id']) }}" style="--event-color: {{ $bg }}">
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
href="{{ route('calendar.events.show', [$event['calendar_slug'], $event['id']]) }}"
hx-get="{{ route('calendar.events.show', [$event['calendar_slug'], $event['id']]) }}"
hx-target="#modal"
hx-push-url="false"
hx-swap="innerHTML"
style="--event-color: {{ $bg }}"
data-calendar="{{ $event['calendar_slug'] }}">
<i class="indicator" aria-label="Calendar indicator"></i>
<span class="title">{{ $event['title'] }}</span>
<time>{{ $event['start_ui'] }}</time>
</a>
@endforeach
@endif
</li>

View File

@ -1,6 +1,7 @@
@props([
'grid' => ['weeks' => []],
'calendars' => [],
'events' => [],
'class' => ''
])
@ -17,7 +18,7 @@
<ol data-weeks="{{ count($grid['weeks']) }}">
@foreach ($grid['weeks'] as $week)
@foreach ($week as $day)
<x-calendar.day :day="$day" :calendars="$calendars" />
<x-calendar.day :day="$day" :events="$events" :calendars="$calendars" />
@endforeach
@endforeach
</ol>

View File

@ -7,13 +7,13 @@
</header>
<figure>
<hgroup>
<span>U</span>
<span>M</span>
<span>T</span>
<span>W</span>
<span>R</span>
<span>F</span>
<span>S</span>
<span>Mo</span>
<span>Tu</span>
<span>We</span>
<span>Th</span>
<span>Fr</span>
<span>Sa</span>
<span>Su</span>
</hgroup>
<form action="/" method="get">
{{ $slot }}

View File

@ -1,78 +0,0 @@
@props([
'name',
'show' => false,
'maxWidth' => '2xl'
])
@php
$maxWidth = [
'sm' => 'sm:max-w-sm',
'md' => 'sm:max-w-md',
'lg' => 'sm:max-w-lg',
'xl' => 'sm:max-w-xl',
'2xl' => 'sm:max-w-2xl',
][$maxWidth];
@endphp
<div
x-data="{
show: @js($show),
focusables() {
// All focusable element types...
let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
return [...$el.querySelectorAll(selector)]
// All non-disabled elements...
.filter(el => ! el.hasAttribute('disabled'))
},
firstFocusable() { return this.focusables()[0] },
lastFocusable() { return this.focusables().slice(-1)[0] },
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
}"
x-init="$watch('show', value => {
if (value) {
document.body.classList.add('overflow-y-hidden');
{{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
} else {
document.body.classList.remove('overflow-y-hidden');
}
})"
x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
x-on:close.stop="show = false"
x-on:keydown.escape.window="show = false"
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
x-show="show"
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
style="display: {{ $show ? 'block' : 'none' }};"
>
<div
x-show="show"
class="fixed inset-0 transform transition-all"
x-on:click="show = false"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<div
x-show="show"
class="mb-6 bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
{{ $slot }}
</div>
</div>

View File

@ -0,0 +1,8 @@
<form method="dialog">
<x-button.icon type="submit" label="Close the modal" autofocus>
<x-icon-x />
</x-button.icon>
</form>
<div class="content">
{{ $slot }}
</div>

View File

@ -0,0 +1,8 @@
<dialog>
<div id="modal"
hx-target="this"
hx-on::after-swap="document.querySelector('dialog').showModal();"
hx-swap="innerHTML">
</div>
</dialog>

View File

@ -0,0 +1,27 @@
<x-modal.content>
<div class="p-6 space-y-4">
<h2 class="text-lg font-semibold">
{{ $event->meta->title ?? '(no title)' }}
</h2>
<p class="text-sm text-gray-600">
{{ $start->format('l, F j, Y · g:i A') }}
@unless ($start->equalTo($end))
&nbsp;&nbsp;
{{ $end->isSameDay($start)
? $end->format('g:i A')
: $end->format('l, F j, Y · g:i A') }}
@endunless
</p>
@if ($event->meta->location)
<p class="text-sm"><strong>Where:</strong> {{ $event->meta->location }}</p>
@endif
@if ($event->meta->description)
<div class="prose max-w-none text-sm">
{!! nl2br(e($event->meta->description)) !!}
</div>
@endif
</div>
</x-modal.content>

View File

@ -0,0 +1,14 @@
<x-app-layout>
<x-slot name="header">
<h1 class="text-xl font-semibold">
{{ $event->meta->title ?? '(no title)' }}
</h1>
<a href="{{ route('calendar.events.edit', [$calendar->id, $event->id]) }}"
class="button button--primary ml-auto">
Edit
</a>
</x-slot>
@include('event.partials.details')
</x-app-layout>

View File

@ -8,8 +8,11 @@
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body id="app">
<!-- app navigation -->
@include('layouts.navigation')
<!-- content -->
<main>
@isset($header)
<header>
@ -22,6 +25,7 @@
</article>
</main>
<!-- messages -->
<aside>
@if (session('toast'))
<div
@ -35,5 +39,9 @@
</div>
@endif
</aside>
<!-- modal -->
<x-modal />
</body>
</html>

View File

@ -14,7 +14,7 @@
<x-nav-link :href="route('calendar.index')" :active="request()->routeIs('calendar*')">
<x-icon-calendar class="w-7 h-7" />
</x-nav-link>
<x-nav-link :href="route('books.index')" :active="request()->routeIs('books*')">
<x-nav-link :href="route('book.index')" :active="request()->routeIs('books*')">
<x-icon-book-user class="w-7 h-7" />
</x-nav-link>
<menu>

View File

@ -0,0 +1,9 @@
<x-app-layout>
<x-slot name="header">
<h1 class="text-xl font-semibold">Add Remote Subscription</h1>
</x-slot>
<x-subscriptions.form
:action="route('subscriptions.store')"
method="POST" />
</x-app-layout>

View File

@ -0,0 +1,10 @@
<x-app-layout>
<x-slot name="header">
<h1 class="text-xl font-semibold">Edit Subscription</h1>
</x-slot>
<x-subscriptions.form
:action="route('subscriptions.update', $subscription)"
method="PUT"
:subscription="$subscription" />
</x-app-layout>

View File

@ -0,0 +1,44 @@
@props(['action', 'method' => 'POST', 'subscription' => null])
<form action="{{ $action }}" method="POST" class="max-w-lg space-y-4 mt-6">
@csrf
@if($method !== 'POST')
@method($method)
@endif
<div>
<label class="block font-medium">Source URL (ICS or CalDAV)</label>
<input name="source"
type="url"
value="{{ old('source', $subscription->source ?? '') }}"
required class="input w-full">
</div>
<div>
<label class="block font-medium">Display name</label>
<input name="displayname"
value="{{ old('displayname', $subscription->displayname ?? '') }}"
class="input w-full">
</div>
<div class="flex space-x-4">
<div class="flex-1">
<label class="block font-medium">Colour</label>
<input name="calendarcolor"
type="color"
value="{{ old('calendarcolor', $subscription->calendarcolor ?? '#888888') }}"
class="input w-20 h-9 p-0">
</div>
<div class="flex-1">
<label class="block font-medium">
Refresh&nbsp;rate <span class="text-xs text-gray-500">(ISO-8601)</span>
</label>
<input name="refreshrate"
placeholder="P1D"
value="{{ old('refreshrate', $subscription->refreshrate ?? '') }}"
class="input w-full">
</div>
</div>
<button class="button button--primary">Save</button>
</form>

View File

@ -0,0 +1,47 @@
<x-app-layout>
<x-slot name="header">
<h1 class="text-xl font-semibold">Remote Subscriptions</h1>
<a href="{{ route('subscriptions.create') }}"
class="button button--primary ml-auto">+ New</a>
</x-slot>
<table class="w-full mt-6 text-sm">
<thead class="text-left text-gray-500">
<tr>
<th>Name</th>
<th>Source URL</th>
<th class="w-28">Colour</th>
<th class="w-32"></th>
</tr>
</thead>
<tbody>
@forelse ($subs as $sub)
<tr class="border-t">
<td>{{ $sub->displayname ?: '(no label)' }}</td>
<td class="truncate max-w-md">{{ $sub->source }}</td>
<td>
<span class="inline-block w-4 h-4 rounded-sm"
style="background: {{ $sub->calendarcolor ?? '#888' }}"></span>
</td>
<td class="text-right space-x-2">
<a href="{{ route('subscriptions.edit', $sub) }}"
class="text-blue-600 hover:underline">edit</a>
<form action="{{ route('subscriptions.destroy', $sub) }}"
method="POST" class="inline">
@csrf @method('DELETE')
<button class="text-red-600 hover:underline"
onclick="return confirm('Remove subscription?')">
delete
</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="4" class="py-6 text-center text-gray-400">
No subscriptions yet
</td></tr>
@endforelse
</tbody>
</table>
</x-app-layout>

File diff suppressed because one or more lines are too long

View File

@ -7,6 +7,7 @@ use App\Http\Controllers\CalendarController;
use App\Http\Controllers\CardController;
use App\Http\Controllers\DavController;
use App\Http\Controllers\EventController;
use App\Http\Controllers\IcsController;
use App\Http\Controllers\SubscriptionController;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@ -38,31 +39,48 @@ Route::middleware('auth')->group(function () {
Route::patch ('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
/* Calendars CRUD */
Route::resource('calendar', CalendarController::class);
/* calendar core */
Route::middleware('auth')->group(function () {
// list, create, store, show, edit, update, destroy
Route::resource('calendar', CalendarController::class)
->whereUuid('calendar'); // constrain the {calendar} param
});
/* Nested Events CRUD */
Route::prefix('calendar/{calendar}')
/* calendar other */
Route::middleware('auth')
->prefix('calendar')
->name('calendar.')
->group(function () {
Route::get ('events/create', [EventController::class, 'create'])->name('events.create');
Route::post('events', [EventController::class, 'store' ])->name('events.store');
Route::get ('events/{event}/edit', [EventController::class, 'edit' ])->name('events.edit');
Route::put ('events/{event}', [EventController::class, 'update'])->name('events.update');
// remote calendar subscriptions
Route::resource('subscriptions', SubscriptionController::class)
->except(['show']); // index, create, store, edit, update, destroy
// events
Route::prefix('{calendar}')->whereUuid('calendar')->group(function () {
// create & store
Route::get ('event/create', [EventController::class, 'create'])->name('events.create');
Route::post('event', [EventController::class, 'store' ])->name('events.store');
// read
Route::get ('event/{event}', [EventController::class, 'show' ])->name('events.show');
// edit & update
Route::get ('event/{event}/edit', [EventController::class, 'edit' ])->name('events.edit');
Route::put ('event/{event}', [EventController::class, 'update'])->name('events.update');
// delete
Route::delete('event/{event}', [EventController::class, 'destroy'])->name('events.destroy');
});
});
/** address books */
Route::resource('books', BookController::class)
->names('books') // books.index, books.create, …
->parameter('books', 'book'); // {book} binding
Route::resource('book', BookController::class)
->names('book') // books.index, books.create, …
->parameter('book', 'book'); // {book} binding
/** contacts inside a book
nested so urls look like /books/{book}/contacts/{contact} */
Route::resource('books.contacts', ContactController::class)
->names('books.contacts')
/*Route::resource('book.contacts', CardController::class)
->names('book.contacts')
->parameter('contacts', 'contact')
->except(['index']) // you may add an index later
->shallow();
->shallow();*/
});
/* Breeze auth routes (login, register, password reset, etc.) */
@ -84,5 +102,11 @@ Route::match(
)->where('any', '.*')
->withoutMiddleware([VerifyCsrfToken::class]);
// subscriptions
Route::get('/subscriptions/{calendarUri}.ics', [SubscriptionController::class, 'download']);
// our subscriptions
Route::get('/ics/{calendarUri}.ics', [IcsController::class, 'download']);
// remote subscriptions
Route::middleware(['auth'])->group(function () {
Route::resource('calendar/subscriptions', SubscriptionController::class)
->except(['show']); // index • create • store • edit • update • destroy
});