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:
parent
7efcf5cf55
commit
c58a498e44
@ -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.');
|
||||
}
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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 sabre’s 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);
|
||||
}
|
||||
}
|
||||
|
67
app/Http/Controllers/IcsController.php
Normal file
67
app/Http/Controllers/IcsController.php
Normal 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 ?? '');
|
||||
}
|
||||
}
|
@ -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!'));
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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';
|
||||
|
@ -24,12 +24,6 @@ class Event extends Model
|
||||
'calendardata',
|
||||
];
|
||||
|
||||
/* casts */
|
||||
protected $casts = [
|
||||
'start_at' => 'datetime',
|
||||
'end_at' => 'datetime',
|
||||
];
|
||||
|
||||
/* owning calendar */
|
||||
public function calendar(): BelongsTo
|
||||
{
|
||||
|
31
app/Models/Subscription.php
Normal file
31
app/Models/Subscription.php
Normal 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',
|
||||
];
|
||||
}
|
70
app/Policies/EventPolicy.php
Normal file
70
app/Policies/EventPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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
|
||||
{
|
||||
|
@ -9,7 +9,11 @@ return new class extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('calendar_meta', function (Blueprint $table) {
|
||||
|
||||
// // FK = PK to Sabre’s 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
|
||||
|
@ -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
15
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -12,6 +12,7 @@
|
||||
@import './lib/indicator.css';
|
||||
@import './lib/input.css';
|
||||
@import './lib/mini.css';
|
||||
@import './lib/modal.css';
|
||||
|
||||
/** plugins */
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
53
resources/css/lib/modal.css
Normal file
53
resources/css/lib/modal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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 isn’t 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));
|
||||
});
|
||||
|
1
resources/svg/icons/x.svg
Normal file
1
resources/svg/icons/x.svg
Normal 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 |
4
resources/views/book/create.blade.php
Normal file
4
resources/views/book/create.blade.php
Normal file
@ -0,0 +1,4 @@
|
||||
<x-layout>
|
||||
<h1>Create Address Book</h1>
|
||||
@include('addressbooks.form', ['action' => route('book.store'), 'isEdit' => false])
|
||||
</x-layout>
|
4
resources/views/book/edit.blade.php
Normal file
4
resources/views/book/edit.blade.php
Normal file
@ -0,0 +1,4 @@
|
||||
<x-layout>
|
||||
<h1>Edit Address Book</h1>
|
||||
@include('addressbooks.form', ['action' => route('book.update', $addressbook), 'isEdit' => true])
|
||||
</x-layout>
|
@ -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>
|
@ -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>
|
@ -1,4 +0,0 @@
|
||||
<x-layout>
|
||||
<h1>Create Address Book</h1>
|
||||
@include('addressbooks.form', ['action' => route('addressbooks.store'), 'isEdit' => false])
|
||||
</x-layout>
|
@ -1,4 +0,0 @@
|
||||
<x-layout>
|
||||
<h1>Edit Address Book</h1>
|
||||
@include('addressbooks.form', ['action' => route('addressbooks.update', $addressbook), 'isEdit' => true])
|
||||
</x-layout>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 }}
|
||||
|
@ -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>
|
8
resources/views/components/modal/content.blade.php
Normal file
8
resources/views/components/modal/content.blade.php
Normal 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>
|
8
resources/views/components/modal/index.blade.php
Normal file
8
resources/views/components/modal/index.blade.php
Normal 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>
|
||||
|
27
resources/views/event/partials/details.blade.php
Normal file
27
resources/views/event/partials/details.blade.php
Normal 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))
|
||||
–
|
||||
{{ $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>
|
14
resources/views/event/show.blade.php
Normal file
14
resources/views/event/show.blade.php
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
9
resources/views/subscription/create.blade.php
Normal file
9
resources/views/subscription/create.blade.php
Normal 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>
|
10
resources/views/subscription/edit.blade.php
Normal file
10
resources/views/subscription/edit.blade.php
Normal 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>
|
44
resources/views/subscription/form.blade.php
Normal file
44
resources/views/subscription/form.blade.php
Normal 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 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>
|
47
resources/views/subscription/index.blade.php
Normal file
47
resources/views/subscription/index.blade.php
Normal 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
@ -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
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user