Builds out individual calendar settings with views and save methods; solid icons added with updates to pagelink component; sync meta fields added to subscriptions table; new textarea component
@ -271,7 +271,7 @@ class CalendarController extends Controller
|
|||||||
// update the calendar instance row
|
// update the calendar instance row
|
||||||
$instance = CalendarInstance::create([
|
$instance = CalendarInstance::create([
|
||||||
'calendarid' => $calId,
|
'calendarid' => $calId,
|
||||||
'principaluri' => auth()->user()->principal_uri,
|
'principaluri' => auth()->user()->uri,
|
||||||
'uri' => Str::uuid(),
|
'uri' => Str::uuid(),
|
||||||
'displayname' => $data['name'],
|
'displayname' => $data['name'],
|
||||||
'description' => $data['description'] ?? null,
|
'description' => $data['description'] ?? null,
|
||||||
@ -300,7 +300,7 @@ class CalendarController extends Controller
|
|||||||
|
|
||||||
$calendar->load([
|
$calendar->load([
|
||||||
'meta',
|
'meta',
|
||||||
'instances' => fn ($q) => $q->where('principaluri', auth()->user()->principal_uri),
|
'instances' => fn ($q) => $q->where('principaluri', auth()->user()->uri),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* grab the single instance for convenience in the view */
|
/* grab the single instance for convenience in the view */
|
||||||
@ -329,7 +329,7 @@ class CalendarController extends Controller
|
|||||||
$calendar->load([
|
$calendar->load([
|
||||||
'meta',
|
'meta',
|
||||||
'instances' => fn ($q) =>
|
'instances' => fn ($q) =>
|
||||||
$q->where('principaluri', auth()->user()->principal_uri),
|
$q->where('principaluri', auth()->user()->uri),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$instance = $calendar->instances->first(); // may be null but shouldn’t
|
$instance = $calendar->instances->first(); // may be null but shouldn’t
|
||||||
@ -353,7 +353,7 @@ class CalendarController extends Controller
|
|||||||
|
|
||||||
// update the instance row
|
// update the instance row
|
||||||
$calendar->instances()
|
$calendar->instances()
|
||||||
->where('principaluri', auth()->user()->principal_uri)
|
->where('principaluri', auth()->user()->uri)
|
||||||
->update([
|
->update([
|
||||||
'displayname' => $data['name'],
|
'displayname' => $data['name'],
|
||||||
'description' => $data['description'] ?? '',
|
'description' => $data['description'] ?? '',
|
||||||
|
|||||||
@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\CalendarInstance;
|
||||||
|
use App\Models\CalendarMeta;
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Redirect;
|
||||||
|
|
||||||
class CalendarSettingsController extends Controller
|
class CalendarSettingsController extends Controller
|
||||||
{
|
{
|
||||||
@ -108,8 +113,8 @@ class CalendarSettingsController extends Controller
|
|||||||
return $this->frame(
|
return $this->frame(
|
||||||
'calendar.settings.subscribe',
|
'calendar.settings.subscribe',
|
||||||
[
|
[
|
||||||
'title' => 'Subscribe to a calendar',
|
'title' => __('calendar.settings.subscribe.title'),
|
||||||
'sub' => 'Add an `.ics` calender from another service'
|
'sub' => __('calendar.settings.subscribe.subtitle'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,14 +144,115 @@ class CalendarSettingsController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* individual calendar settings
|
||||||
|
*/
|
||||||
|
public function calendarForm(Request $request, string $calendarUri)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$principalUri = $user->uri;
|
||||||
|
|
||||||
|
// this is the user's "instance" of the calendar (displayname/color/etc live here in sabre)
|
||||||
|
$instance = CalendarInstance::query()
|
||||||
|
->where('principaluri', $principalUri)
|
||||||
|
->where('uri', $calendarUri)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
// your app meta (optional row)
|
||||||
|
$meta = $instance->meta;
|
||||||
|
|
||||||
|
// if it's remote
|
||||||
|
$icsUrl = null;
|
||||||
|
if (($meta?->is_remote ?? false) && $meta?->subscription_id) {
|
||||||
|
$icsUrl = Subscription::query()
|
||||||
|
->whereKey($meta->subscription_id)
|
||||||
|
->value('source');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->frame(
|
||||||
|
'calendar.settings.calendar',
|
||||||
|
[
|
||||||
|
'title' => __('calendar.settings.calendar.title'),
|
||||||
|
'sub' => __('calendar.settings.calendar.subtitle'),
|
||||||
|
'instance' => $instance,
|
||||||
|
'meta' => $meta,
|
||||||
|
'icsUrl' => $icsUrl,
|
||||||
|
'userTz' => $user->timezone,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calendarStore(Request $request, string $calendarUri): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$principalUri = $user->uri;
|
||||||
|
|
||||||
|
$instance = CalendarInstance::query()
|
||||||
|
->where('principaluri', $principalUri)
|
||||||
|
->where('uri', $calendarUri)
|
||||||
|
->with('meta')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'displayname' => ['required', 'string', 'max:100'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'timezone' => ['nullable', 'string', 'max:64'],
|
||||||
|
'color' => ['nullable', 'regex:/^#[0-9A-F]{6}$/i'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$timezone = filled($data['timezone'] ?? null)
|
||||||
|
? $data['timezone']
|
||||||
|
: $user->timezone;
|
||||||
|
|
||||||
|
$color = $data['color'] ?? $instance->resolvedColor();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($instance, $data, $timezone, $color) {
|
||||||
|
|
||||||
|
// update sabre calendar instance (dav-facing)
|
||||||
|
$instance->update([
|
||||||
|
'displayname' => $data['displayname'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'timezone' => $timezone,
|
||||||
|
'calendarcolor' => $color,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// update ui meta (kithkin-facing)
|
||||||
|
CalendarMeta::updateOrCreate(
|
||||||
|
['calendar_id' => $instance->calendarid],
|
||||||
|
[
|
||||||
|
'title' => $data['displayname'],
|
||||||
|
'color' => $color,
|
||||||
|
'color_fg' => contrast_text_color($color),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Redirect::route('calendar.settings.calendars.show', $calendarUri)
|
||||||
|
->with('toast', [
|
||||||
|
'message' => __('calendar.settings.saved'),
|
||||||
|
'type' => 'success',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* content frame handler
|
* content frame handler
|
||||||
*/
|
*/
|
||||||
private function frame(?string $view = null, array $data = [])
|
private function frame(?string $view = null, array $data = [])
|
||||||
{
|
{
|
||||||
|
$user = request()->user();
|
||||||
|
|
||||||
|
/* pull user's calendar instances and adapt ordering keys to calendarorder */
|
||||||
|
$calendars = CalendarInstance::query()
|
||||||
|
->forUser($user)
|
||||||
|
->withUiMeta()
|
||||||
|
->ordered()
|
||||||
|
->get();
|
||||||
|
|
||||||
return view('calendar.settings.index', [
|
return view('calendar.settings.index', [
|
||||||
'view' => $view,
|
'view' => $view,
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
|
'calendars' => $calendars,
|
||||||
|
'timezones' => config('timezones'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,22 +2,21 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use App\Jobs\SyncSubscription;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use App\Models\Subscription;
|
use App\Models\Subscription;
|
||||||
use App\Models\Calendar;
|
use App\Models\Calendar;
|
||||||
use App\Models\CalendarInstance;
|
use App\Models\CalendarInstance;
|
||||||
use App\Models\CalendarMeta;
|
use App\Models\CalendarMeta;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Redirect;
|
||||||
|
|
||||||
class SubscriptionController extends Controller
|
class SubscriptionController extends Controller
|
||||||
{
|
{
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$subs = Subscription::where(
|
$subs = Subscription::where('principaluri', auth()->user()->uri)->get();
|
||||||
'principaluri',
|
|
||||||
auth()->user()->principal_uri
|
|
||||||
)->get();
|
|
||||||
|
|
||||||
return view('subscription.index', compact('subs'));
|
return view('subscription.index', compact('subs'));
|
||||||
}
|
}
|
||||||
@ -36,51 +35,91 @@ class SubscriptionController extends Controller
|
|||||||
'refreshrate' => 'nullable|string|max:10',
|
'refreshrate' => 'nullable|string|max:10',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
DB::transaction(function () use ($request, $data) {
|
$principalUri = $request->user()->uri;
|
||||||
|
$source = rtrim(trim($data['source']), '/');
|
||||||
|
$desc = 'Remote feed: '.$source;
|
||||||
|
|
||||||
/* add entry into calendarsubscriptions first */
|
/* if they already subscribed to this exact feed, don’t create duplicates */
|
||||||
|
if (Subscription::where('principaluri', $principalUri)->where('source', $source)->exists()) {
|
||||||
|
return Redirect::route('calendar.index')->with('toast', [
|
||||||
|
'message' => __('You are already subscribed to that calendar.'),
|
||||||
|
'type' => 'info',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = DB::transaction(function () use ($request, $data, $principalUri, $source, $desc) {
|
||||||
|
|
||||||
|
/* check if a mirror instance already exists */
|
||||||
|
$existingInstance = CalendarInstance::where('principaluri', $principalUri)
|
||||||
|
->where('description', $desc)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
|
||||||
|
/* create the calendarsubscriptions record */
|
||||||
$sub = Subscription::create([
|
$sub = Subscription::create([
|
||||||
'uri' => Str::uuid(),
|
'uri' => (string) Str::uuid(),
|
||||||
'principaluri' => 'principals/'.$request->user()->email,
|
'principaluri' => $principalUri,
|
||||||
'source' => $data['source'],
|
'source' => $source,
|
||||||
'displayname' => $data['displayname'] ?: $data['source'],
|
'displayname' => $data['displayname'] ?: $source,
|
||||||
'calendarcolor' => $data['color'] ?? '#1a1a1a',
|
'calendarcolor' => $data['color'] ?? '#1a1a1a',
|
||||||
'refreshrate' => 'P1D',
|
'refreshrate' => 'P1D',
|
||||||
'lastmodified' => now()->timestamp,
|
'lastmodified' => now()->timestamp,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* create the empty "shadow" calendar container */
|
// choose the calendar container
|
||||||
$calId = Calendar::create([
|
if ($existingInstance) {
|
||||||
'synctoken' => 1,
|
$calId = $existingInstance->calendarid;
|
||||||
'components' => 'VEVENT',
|
|
||||||
])->id;
|
|
||||||
|
|
||||||
/* create the calendarinstance row attached to the user */
|
// keep the mirror instance’s user-facing bits up to date
|
||||||
CalendarInstance::create([
|
$existingInstance->update([
|
||||||
'calendarid' => $calId,
|
'displayname' => $sub->displayname,
|
||||||
'principaluri' => $sub->principaluri,
|
'calendarcolor' => $sub->calendarcolor,
|
||||||
'uri' => Str::uuid(),
|
'timezone' => config('app.timezone', 'UTC'),
|
||||||
'displayname' => $sub->displayname,
|
]);
|
||||||
'description' => 'Remote feed: '.$sub->source,
|
} else {
|
||||||
'calendarcolor' => $sub->calendarcolor,
|
// create new empty calendar container
|
||||||
'timezone' => config('app.timezone', 'UTC'),
|
$calId = Calendar::create([
|
||||||
]);
|
'synctoken' => 1,
|
||||||
|
'components' => 'VEVENT',
|
||||||
|
])->id;
|
||||||
|
|
||||||
/* create our calendar_meta entry */
|
// create mirror calendarinstance row
|
||||||
CalendarMeta::create([
|
CalendarInstance::create([
|
||||||
'calendar_id' => $calId,
|
'calendarid' => $calId,
|
||||||
'subscription_id' => $sub->id,
|
'principaluri' => $sub->principaluri,
|
||||||
'title' => $sub->displayname,
|
'uri' => Str::uuid(),
|
||||||
'color' => $sub->calendarcolor,
|
'displayname' => $sub->displayname,
|
||||||
'color_fg' => contrast_text_color($sub->calendarcolor),
|
'description' => $desc,
|
||||||
'is_shared' => true,
|
'calendarcolor' => $sub->calendarcolor,
|
||||||
'is_remote' => true,
|
'timezone' => config('app.timezone', 'UTC'),
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsert our calendar_meta entry by calendar_id (since that’s your pk)
|
||||||
|
CalendarMeta::updateOrCreate(
|
||||||
|
['calendar_id' => $calId],
|
||||||
|
[
|
||||||
|
'subscription_id' => $sub->id,
|
||||||
|
'title' => $sub->displayname,
|
||||||
|
'color' => $sub->calendarcolor,
|
||||||
|
'color_fg' => contrast_text_color($sub->calendarcolor),
|
||||||
|
'is_shared' => true,
|
||||||
|
'is_remote' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $sub;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// sync immediately so events appear without waiting for the */10 dispatcher
|
||||||
|
SyncSubscription::dispatch($sub)->afterCommit();
|
||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('calendar.index')
|
->route('calendar.index')
|
||||||
->with('toast', __('Subscription added!'));
|
->with('toast', [
|
||||||
|
'message' => __('Subscription added! Syncing events now...'),
|
||||||
|
'type' => 'success',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function edit(Subscription $subscription)
|
public function edit(Subscription $subscription)
|
||||||
|
|||||||
@ -12,83 +12,124 @@ use Illuminate\Bus\Queueable;
|
|||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Http\Client\ConnectionException;
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Sabre\VObject\ParseException;
|
||||||
use Sabre\VObject\Reader;
|
use Sabre\VObject\Reader;
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors a remote iCalendar (ICS) feed into the local Sabre tables.
|
|
||||||
*
|
|
||||||
* • Runs in the background (ShouldQueue).
|
|
||||||
* • Ensures a hidden “mirror” calendar exists once per subscription.
|
|
||||||
* • Upserts every VEVENT into calendarobjects + event_meta.
|
|
||||||
*/
|
|
||||||
class SyncSubscription implements ShouldQueue
|
class SyncSubscription implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
|
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
|
||||||
|
|
||||||
/** @var \App\Models\Subscription */
|
|
||||||
public Subscription $subscription;
|
public Subscription $subscription;
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Subscription $subscription The feed to sync.
|
|
||||||
*/
|
|
||||||
public function __construct(Subscription $subscription)
|
public function __construct(Subscription $subscription)
|
||||||
{
|
{
|
||||||
$this->subscription = $subscription;
|
$this->subscription = $subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Main entry-point executed by the queue worker.
|
|
||||||
*/
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
/**
|
// normalize the source a bit so comparisons/logging are consistent
|
||||||
* download the remote .ics file with retry and a long timeout */
|
$source = rtrim(trim((string) $this->subscription->source), '/');
|
||||||
|
|
||||||
|
// 1) download the remote feed
|
||||||
try {
|
try {
|
||||||
$ics = Http::retry(3, 5000)->timeout(30)
|
$resp = Http::retry(3, 5000)
|
||||||
->withHeaders(['User-Agent' => 'Kithkin CalDAV Bot'])
|
->timeout(30)
|
||||||
->get($this->subscription->source)
|
->withHeaders([
|
||||||
->throw() // throws if not 2xx
|
'User-Agent' => 'Kithkin CalDAV Bot',
|
||||||
->body();
|
'Accept' => 'text/calendar, text/plain;q=0.9, */*;q=0.8',
|
||||||
} catch (ConnectionException | \Throwable $e) {
|
])
|
||||||
|
->get($source);
|
||||||
|
|
||||||
|
$resp->throw();
|
||||||
|
|
||||||
|
$body = $resp->body();
|
||||||
|
$contentType = (string) $resp->header('Content-Type');
|
||||||
|
}
|
||||||
|
catch (ConnectionException | RequestException | \Throwable $e)
|
||||||
|
{
|
||||||
|
$this->markSyncFailed('fetch_failed', $e->getMessage());
|
||||||
|
|
||||||
Log::warning('Feed fetch failed', [
|
Log::warning('Feed fetch failed', [
|
||||||
'sub' => $this->subscription->id,
|
'sub' => $this->subscription->id,
|
||||||
'msg' => $e->getMessage(),
|
'url' => $source,
|
||||||
|
'msg' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* mark the job as failed and let Horizon / queue retry logic handle it */
|
|
||||||
$this->fail($e);
|
$this->fail($e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 2) ensure we actually got an ICS payload, not HTML or other content
|
||||||
* get the mirror calendar, or lazy create it */
|
if (! $this->looksLikeIcs($body))
|
||||||
$meta = $this->subscription->meta ?? $this->subscription->meta()->create();
|
{
|
||||||
|
$this->markSyncFailed('not_ical', 'remote feed did not return vcALENDAR data');
|
||||||
|
|
||||||
|
Log::warning('Feed did not return iCalendar data', [
|
||||||
|
'sub' => $this->subscription->id,
|
||||||
|
'url' => $source,
|
||||||
|
'status' => $resp->status(),
|
||||||
|
'content_type' => $contentType ?? null,
|
||||||
|
'starts_with' => substr(ltrim($body), 0, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->fail(new \RuntimeException('Remote feed did not return VCALENDAR data.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) get or create mirror calendar
|
||||||
|
$meta = $this->subscription->meta()->first() ?? $this->subscription->meta()->create();
|
||||||
|
|
||||||
if (! $meta->calendar_id) {
|
if (! $meta->calendar_id) {
|
||||||
$meta->calendar_id = $this->createMirrorCalendar($meta);
|
$meta->calendar_id = $this->createMirrorCalendar($meta, $source);
|
||||||
$meta->save();
|
$meta->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$calendarId = $meta->calendar_id;
|
$calendarId = (int) $meta->calendar_id;
|
||||||
|
|
||||||
/**
|
// 4) parse the VCALENDAR
|
||||||
* parse and upsert vevents */
|
try {
|
||||||
$vcalendar = Reader::read($ics);
|
$vcalendar = Reader::read($body);
|
||||||
|
}
|
||||||
|
catch (ParseException | \Throwable $e)
|
||||||
|
{
|
||||||
|
$this->markSyncFailed('parse_failed', $e->getMessage());
|
||||||
|
|
||||||
Log::info('Syncing subscription '.$this->subscription->id);
|
Log::warning('ICS parse failed', [
|
||||||
|
'sub' => $this->subscription->id,
|
||||||
|
'url' => $source,
|
||||||
|
'msg' => $e->getMessage(),
|
||||||
|
'starts_with' => substr(ltrim($body), 0, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
foreach ($vcalendar->VEVENT as $vevent) {
|
$this->fail($e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Syncing subscription', [
|
||||||
|
'sub' => $this->subscription->id,
|
||||||
|
'url' => $source,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 5) upsert events
|
||||||
|
foreach (($vcalendar->VEVENT ?? []) as $vevent) {
|
||||||
|
$uid = isset($vevent->UID) ? (string) $vevent->UID : null;
|
||||||
|
$dtStart = $vevent->DTSTART ?? null;
|
||||||
|
|
||||||
|
// skip malformed events (rare, but it happens)
|
||||||
|
if (! $uid || ! $dtStart) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$uid = (string) $vevent->UID;
|
|
||||||
$now = now()->timestamp;
|
$now = now()->timestamp;
|
||||||
$blob = (string) $vevent->serialize();
|
$blob = (string) $vevent->serialize();
|
||||||
|
|
||||||
/** @var Event $object */
|
|
||||||
$object = Event::updateOrCreate(
|
$object = Event::updateOrCreate(
|
||||||
['uid' => $uid, 'calendarid' => $calendarId],
|
['uid' => $uid, 'calendarid' => $calendarId],
|
||||||
[
|
[
|
||||||
@ -101,60 +142,100 @@ class SyncSubscription implements ShouldQueue
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$startUtc = Carbon::parse($vevent->DTSTART->getDateTime());
|
// sabre gives DateTime objects here; Carbon::instance is safest
|
||||||
$endUtc = isset($vevent->DTEND)
|
$start = Carbon::instance($dtStart->getDateTime())->utc();
|
||||||
? Carbon::parse($vevent->DTEND->getDateTime())
|
$end = isset($vevent->DTEND)
|
||||||
: $startUtc;
|
? Carbon::instance($vevent->DTEND->getDateTime())->utc()
|
||||||
|
: $start;
|
||||||
|
|
||||||
EventMeta::upsertForEvent($object->id, [
|
EventMeta::upsertForEvent($object->id, [
|
||||||
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
|
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
|
||||||
'description' => (string) ($vevent->DESCRIPTION ?? ''),
|
'description' => (string) ($vevent->DESCRIPTION ?? ''),
|
||||||
'location' => (string) ($vevent->LOCATION ?? ''),
|
'location' => (string) ($vevent->LOCATION ?? ''),
|
||||||
'all_day' => $vevent->DTSTART->isFloating(),
|
'all_day' => $dtStart->isFloating(),
|
||||||
'start_at' => $startUtc->utc(),
|
'start_at' => $start,
|
||||||
'end_at' => $endUtc->utc(),
|
'end_at' => $end,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::info('Syncing subscription post foreach '.$this->subscription->id);
|
// sync is ok
|
||||||
|
$this->markSyncOk();
|
||||||
|
|
||||||
|
Log::info('Syncing subscription complete', [
|
||||||
|
'sub' => $this->subscription->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function looksLikeIcs(string $body): bool
|
||||||
|
{
|
||||||
|
// cheap + effective: an .ics must include BEGIN:VCALENDAR
|
||||||
|
// allow leading whitespace/bom
|
||||||
|
$haystack = ltrim($body);
|
||||||
|
return str_contains($haystack, 'BEGIN:VCALENDAR');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mirrorDescription(string $source): string
|
||||||
|
{
|
||||||
|
return 'Remote feed: '.$source;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily builds the shadow calendar + instance when missing
|
* Lazily builds the shadow calendar + instance when missing.
|
||||||
* (for legacy subscriptions added before we moved creation to the controller).
|
|
||||||
*/
|
*/
|
||||||
private function createMirrorCalendar($meta): int
|
private function createMirrorCalendar($meta, string $source): int
|
||||||
{
|
{
|
||||||
// check if controller created one already and return it if so
|
|
||||||
if ($meta->calendar_id) {
|
if ($meta->calendar_id) {
|
||||||
return $meta->calendar_id;
|
return (int) $meta->calendar_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if a mirror calendar already exists, and return that if so
|
$desc = $this->mirrorDescription($source);
|
||||||
|
|
||||||
$existing = CalendarInstance::where('principaluri', $this->subscription->principaluri)
|
$existing = CalendarInstance::where('principaluri', $this->subscription->principaluri)
|
||||||
->where('description', 'Remote feed: '.$this->subscription->source)
|
->where('description', $desc)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
return $existing->calendarid;
|
return (int) $existing->calendarid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise create the new master calendar in `calendars`
|
|
||||||
$calendar = Calendar::create([
|
$calendar = Calendar::create([
|
||||||
'synctoken' => 1,
|
'synctoken' => 1,
|
||||||
'components' => 'VEVENT',
|
'components' => 'VEVENT',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// attach an instance for this user
|
|
||||||
CalendarInstance::create([
|
CalendarInstance::create([
|
||||||
'calendarid' => $calendar->id,
|
'calendarid' => $calendar->id,
|
||||||
'principaluri' => $this->subscription->principaluri,
|
'principaluri' => $this->subscription->principaluri,
|
||||||
'uri' => Str::uuid(),
|
'uri' => (string) Str::uuid(),
|
||||||
'displayname' => $this->subscription->displayname,
|
'displayname' => $this->subscription->displayname,
|
||||||
'description' => 'Remote feed: '.$this->subscription->source,
|
'description' => $desc,
|
||||||
'calendarcolor' => $meta->color ?? '#1a1a1a',
|
'calendarcolor' => $meta->color ?? '#1a1a1a',
|
||||||
'timezone' => config('app.timezone', 'UTC'),
|
'timezone' => config('app.timezone', 'UTC'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $calendar->id;
|
return (int) $calendar->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sync helpers
|
||||||
|
*/
|
||||||
|
private function markSyncOk(): void
|
||||||
|
{
|
||||||
|
$this->subscription->forceFill([
|
||||||
|
'last_sync_status' => 'ok',
|
||||||
|
'last_sync_error' => null,
|
||||||
|
'last_sync_at' => now(),
|
||||||
|
'lastmodified' => now()->timestamp, // sabre uses int timestamps
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markSyncFailed(string $status, string $message): void
|
||||||
|
{
|
||||||
|
$this->subscription->forceFill([
|
||||||
|
'last_sync_status' => $status,
|
||||||
|
'last_sync_error' => mb_substr($message, 0, 5000),
|
||||||
|
'last_sync_at' => now(),
|
||||||
|
'lastmodified' => now()->timestamp,
|
||||||
|
])->save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
class CalendarInstance extends Model
|
class CalendarInstance extends Model
|
||||||
{
|
{
|
||||||
@ -20,11 +22,73 @@ class CalendarInstance extends Model
|
|||||||
'timezone',
|
'timezone',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $appends = ['is_remote'];
|
||||||
|
|
||||||
public function calendar(): BelongsTo
|
public function calendar(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Calendar::class, 'calendarid');
|
return $this->belongsTo(Calendar::class, 'calendarid');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ui meta for this instance’s underlying calendar container
|
||||||
|
public function meta(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(CalendarMeta::class, 'calendar_id', 'calendarid');
|
||||||
|
}
|
||||||
|
|
||||||
|
// convenient computed flag (defaults false when no meta row exists)
|
||||||
|
public function getIsRemoteAttribute(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->meta?->is_remote ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* common scopes
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function scopeForUser(Builder $query, User $user): Builder
|
||||||
|
{
|
||||||
|
return $query->where('principaluri', $user->uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOrdered(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->orderBy('calendarorder')->orderBy('displayname');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeWithUiMeta(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->with('meta');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* color accessors
|
||||||
|
*/
|
||||||
|
|
||||||
|
public function resolvedColor(?string $fallback = null): string
|
||||||
|
{
|
||||||
|
// prefer meta color, fall back to sabre color, then default
|
||||||
|
return $this->meta?->color
|
||||||
|
?? $this->calendarcolor
|
||||||
|
?? $fallback
|
||||||
|
?? '#1a1a1a';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolvedColorFg(?string $fallback = null): string
|
||||||
|
{
|
||||||
|
return $this->meta?->color_fg
|
||||||
|
?? ($this->resolvedColor($fallback)
|
||||||
|
? contrast_text_color($this->resolvedColor($fallback))
|
||||||
|
: ($fallback ?? '#ffffff'));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* CalDAV accessors
|
||||||
|
*/
|
||||||
|
|
||||||
public function caldavUrl(): string
|
public function caldavUrl(): string
|
||||||
{
|
{
|
||||||
// e.g. https://kithkin.lan/dav/calendars/1/48f888f3-c5c5-…/
|
// e.g. https://kithkin.lan/dav/calendars/1/48f888f3-c5c5-…/
|
||||||
|
|||||||
@ -3,16 +3,12 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\CalendarMeta;
|
use App\Models\CalendarMeta;
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class Subscription extends Model
|
class Subscription extends Model
|
||||||
{
|
{
|
||||||
/** basic table mapping */
|
protected $table = 'calendarsubscriptions';
|
||||||
protected $table = 'calendarsubscriptions';
|
public $timestamps = false;
|
||||||
public $timestamps = false; // sabre table
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'uri',
|
'uri',
|
||||||
@ -25,18 +21,25 @@ class Subscription extends Model
|
|||||||
'striptodos',
|
'striptodos',
|
||||||
'stripalarms',
|
'stripalarms',
|
||||||
'stripattachments',
|
'stripattachments',
|
||||||
|
|
||||||
|
// sync fields
|
||||||
|
'last_sync_status',
|
||||||
|
'last_sync_error',
|
||||||
|
'last_sync_at',
|
||||||
|
'lastmodified',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'striptodos' => 'bool',
|
'striptodos' => 'bool',
|
||||||
'stripalarms' => 'bool',
|
'stripalarms' => 'bool',
|
||||||
'stripattachments' => 'bool',
|
'stripattachments' => 'bool',
|
||||||
|
|
||||||
|
// sync fields
|
||||||
|
'last_sync_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** relationship to meta row */
|
|
||||||
public function meta()
|
public function meta()
|
||||||
{
|
{
|
||||||
return $this->hasOne(\App\Models\CalendarMeta::class,
|
return $this->hasOne(CalendarMeta::class, 'subscription_id');
|
||||||
'subscription_id');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
152
config/timezones.php
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Curated timezone list (IANA identifiers)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Keys are the values you store (e.g. "America/New_York").
|
||||||
|
| Values are the human labels you show in the UI.
|
||||||
|
|
|
||||||
|
| Structure supports optgroups: [ 'Group' => [ 'TZ' => 'Label', ... ] ]
|
||||||
|
*/
|
||||||
|
|
||||||
|
'Common' => [
|
||||||
|
'UTC' => 'UTC',
|
||||||
|
],
|
||||||
|
|
||||||
|
'United States & Canada' => [
|
||||||
|
'America/New_York' => 'Eastern (New York)',
|
||||||
|
'America/Detroit' => 'Eastern (Detroit)',
|
||||||
|
'America/Toronto' => 'Eastern (Toronto)',
|
||||||
|
|
||||||
|
'America/Chicago' => 'Central (Chicago)',
|
||||||
|
'America/Winnipeg' => 'Central (Winnipeg)',
|
||||||
|
|
||||||
|
'America/Denver' => 'Mountain (Denver)',
|
||||||
|
'America/Edmonton' => 'Mountain (Edmonton)',
|
||||||
|
'America/Phoenix' => 'Arizona (Phoenix)',
|
||||||
|
|
||||||
|
'America/Los_Angeles' => 'Pacific (Los Angeles)',
|
||||||
|
'America/Vancouver' => 'Pacific (Vancouver)',
|
||||||
|
|
||||||
|
'America/Anchorage' => 'Alaska (Anchorage)',
|
||||||
|
'Pacific/Honolulu' => 'Hawaii (Honolulu)',
|
||||||
|
],
|
||||||
|
|
||||||
|
'Mexico, Central America & Caribbean' => [
|
||||||
|
'America/Mexico_City' => 'Mexico (Mexico City)',
|
||||||
|
'America/Tijuana' => 'Mexico (Tijuana)',
|
||||||
|
'America/Guatemala' => 'Guatemala',
|
||||||
|
'America/Costa_Rica' => 'Costa Rica',
|
||||||
|
'America/Panama' => 'Panama',
|
||||||
|
'America/Havana' => 'Cuba (Havana)',
|
||||||
|
'America/Jamaica' => 'Jamaica',
|
||||||
|
'America/Puerto_Rico' => 'Puerto Rico',
|
||||||
|
],
|
||||||
|
|
||||||
|
'South America' => [
|
||||||
|
'America/Bogota' => 'Colombia (Bogotá)',
|
||||||
|
'America/Lima' => 'Peru (Lima)',
|
||||||
|
'America/Santiago' => 'Chile (Santiago)',
|
||||||
|
'America/Argentina/Buenos_Aires' => 'Argentina (Buenos Aires)',
|
||||||
|
'America/Sao_Paulo' => 'Brazil (São Paulo)',
|
||||||
|
'America/Montevideo' => 'Uruguay (Montevideo)',
|
||||||
|
'America/Caracas' => 'Venezuela (Caracas)',
|
||||||
|
],
|
||||||
|
|
||||||
|
'Europe' => [
|
||||||
|
'Europe/London' => 'United Kingdom (London)',
|
||||||
|
'Europe/Dublin' => 'Ireland (Dublin)',
|
||||||
|
'Europe/Lisbon' => 'Portugal (Lisbon)',
|
||||||
|
|
||||||
|
'Europe/Paris' => 'France (Paris)',
|
||||||
|
'Europe/Brussels' => 'Belgium (Brussels)',
|
||||||
|
'Europe/Amsterdam' => 'Netherlands (Amsterdam)',
|
||||||
|
'Europe/Berlin' => 'Germany (Berlin)',
|
||||||
|
'Europe/Zurich' => 'Switzerland (Zurich)',
|
||||||
|
'Europe/Rome' => 'Italy (Rome)',
|
||||||
|
'Europe/Madrid' => 'Spain (Madrid)',
|
||||||
|
'Europe/Barcelona' => 'Spain (Barcelona)',
|
||||||
|
'Europe/Vienna' => 'Austria (Vienna)',
|
||||||
|
'Europe/Prague' => 'Czechia (Prague)',
|
||||||
|
'Europe/Warsaw' => 'Poland (Warsaw)',
|
||||||
|
'Europe/Stockholm' => 'Sweden (Stockholm)',
|
||||||
|
'Europe/Oslo' => 'Norway (Oslo)',
|
||||||
|
'Europe/Copenhagen' => 'Denmark (Copenhagen)',
|
||||||
|
'Europe/Helsinki' => 'Finland (Helsinki)',
|
||||||
|
|
||||||
|
'Europe/Athens' => 'Greece (Athens)',
|
||||||
|
'Europe/Bucharest' => 'Romania (Bucharest)',
|
||||||
|
'Europe/Kyiv' => 'Ukraine (Kyiv)',
|
||||||
|
'Europe/Istanbul' => 'Turkey (Istanbul)',
|
||||||
|
|
||||||
|
'Europe/Moscow' => 'Russia (Moscow)',
|
||||||
|
],
|
||||||
|
|
||||||
|
'Africa' => [
|
||||||
|
'Africa/Casablanca' => 'Morocco (Casablanca)',
|
||||||
|
'Africa/Algiers' => 'Algeria (Algiers)',
|
||||||
|
'Africa/Tunis' => 'Tunisia (Tunis)',
|
||||||
|
|
||||||
|
'Africa/Lagos' => 'Nigeria (Lagos)',
|
||||||
|
'Africa/Accra' => 'Ghana (Accra)',
|
||||||
|
|
||||||
|
'Africa/Cairo' => 'Egypt (Cairo)',
|
||||||
|
'Africa/Johannesburg' => 'South Africa (Johannesburg)',
|
||||||
|
'Africa/Nairobi' => 'Kenya (Nairobi)',
|
||||||
|
],
|
||||||
|
|
||||||
|
'Middle East' => [
|
||||||
|
'Asia/Jerusalem' => 'Israel (Jerusalem)',
|
||||||
|
'Asia/Beirut' => 'Lebanon (Beirut)',
|
||||||
|
'Asia/Amman' => 'Jordan (Amman)',
|
||||||
|
'Asia/Baghdad' => 'Iraq (Baghdad)',
|
||||||
|
'Asia/Riyadh' => 'Saudi Arabia (Riyadh)',
|
||||||
|
'Asia/Dubai' => 'UAE (Dubai)',
|
||||||
|
'Asia/Tehran' => 'Iran (Tehran)',
|
||||||
|
],
|
||||||
|
|
||||||
|
'Asia' => [
|
||||||
|
'Asia/Kolkata' => 'India (Kolkata)',
|
||||||
|
'Asia/Kathmandu' => 'Nepal (Kathmandu)',
|
||||||
|
'Asia/Dhaka' => 'Bangladesh (Dhaka)',
|
||||||
|
'Asia/Karachi' => 'Pakistan (Karachi)',
|
||||||
|
'Asia/Colombo' => 'Sri Lanka (Colombo)',
|
||||||
|
|
||||||
|
'Asia/Bangkok' => 'Thailand (Bangkok)',
|
||||||
|
'Asia/Singapore' => 'Singapore',
|
||||||
|
'Asia/Kuala_Lumpur' => 'Malaysia (Kuala Lumpur)',
|
||||||
|
'Asia/Jakarta' => 'Indonesia (Jakarta)',
|
||||||
|
|
||||||
|
'Asia/Hong_Kong' => 'Hong Kong',
|
||||||
|
'Asia/Shanghai' => 'China (Shanghai)',
|
||||||
|
'Asia/Taipei' => 'Taiwan (Taipei)',
|
||||||
|
'Asia/Seoul' => 'South Korea (Seoul)',
|
||||||
|
'Asia/Tokyo' => 'Japan (Tokyo)',
|
||||||
|
'Asia/Manila' => 'Philippines (Manila)',
|
||||||
|
|
||||||
|
'Asia/Ulaanbaatar' => 'Mongolia (Ulaanbaatar)',
|
||||||
|
],
|
||||||
|
|
||||||
|
'Australia & New Zealand' => [
|
||||||
|
'Australia/Perth' => 'Australia (Perth)',
|
||||||
|
'Australia/Adelaide' => 'Australia (Adelaide)',
|
||||||
|
'Australia/Brisbane' => 'Australia (Brisbane)',
|
||||||
|
'Australia/Sydney' => 'Australia (Sydney)',
|
||||||
|
'Australia/Melbourne' => 'Australia (Melbourne)',
|
||||||
|
'Australia/Hobart' => 'Australia (Hobart)',
|
||||||
|
|
||||||
|
'Pacific/Auckland' => 'New Zealand (Auckland)',
|
||||||
|
'Pacific/Chatham' => 'New Zealand (Chatham Islands)',
|
||||||
|
],
|
||||||
|
|
||||||
|
'Pacific Islands' => [
|
||||||
|
'Pacific/Fiji' => 'Fiji',
|
||||||
|
'Pacific/Guam' => 'Guam',
|
||||||
|
'Pacific/Tahiti' => 'French Polynesia (Tahiti)',
|
||||||
|
'Pacific/Apia' => 'Samoa (Apia)',
|
||||||
|
'Pacific/Port_Moresby'=> 'Papua New Guinea (Port Moresby)',
|
||||||
|
],
|
||||||
|
];
|
||||||
@ -7,18 +7,16 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
return new class extends Migration {
|
return new class extends Migration {
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('calendarinstances', function (Blueprint $table) {
|
// enforce "one subscription per feed per user"
|
||||||
$table->unique(
|
Schema::table('calendarsubscriptions', function (Blueprint $table) {
|
||||||
['principaluri', 'description'],
|
$table->unique(['principaluri', 'source'], 'uniq_subscription_principal_source');
|
||||||
'uniq_user_feed_mirror'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::table('calendarinstances', function (Blueprint $table) {
|
Schema::table('calendarsubscriptions', function (Blueprint $table) {
|
||||||
$table->dropUnique('uniq_user_feed_mirror');
|
$table->dropUnique('uniq_subscription_principal_source');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('calendarsubscriptions', function (Blueprint $table) {
|
||||||
|
// keep these nullable so legacy rows work without backfill
|
||||||
|
$table->string('last_sync_status', 20)->nullable()->after('lastmodified');
|
||||||
|
$table->text('last_sync_error')->nullable()->after('last_sync_status');
|
||||||
|
$table->timestamp('last_sync_at')->nullable()->after('last_sync_error');
|
||||||
|
|
||||||
|
// optional but handy for querying in the ui
|
||||||
|
$table->index('last_sync_status');
|
||||||
|
$table->index('last_sync_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('calendarsubscriptions', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['last_sync_status']);
|
||||||
|
$table->dropIndex(['last_sync_at']);
|
||||||
|
|
||||||
|
$table->dropColumn([
|
||||||
|
'last_sync_status',
|
||||||
|
'last_sync_error',
|
||||||
|
'last_sync_at',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -12,17 +12,29 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// settings
|
'color' => 'Color',
|
||||||
|
'description' => 'Description',
|
||||||
|
'ics' => [
|
||||||
|
'url' => 'ICS URL',
|
||||||
|
],
|
||||||
|
'name' => 'Calendar name',
|
||||||
'settings' => [
|
'settings' => [
|
||||||
|
'calendar' => [
|
||||||
|
'title' => 'Calendar settings',
|
||||||
|
'subtitle' => 'Details and settings for <strong>:calendar</strong>.'
|
||||||
|
],
|
||||||
'language_region' => [
|
'language_region' => [
|
||||||
'title' => 'Language and region',
|
'title' => 'Language and region',
|
||||||
'subtitle' => 'Choose your default language, region, and formatting preferences for calendars. These affect how dates and times are displayed throughout Kithkin.',
|
'subtitle' => 'Choose your default language, region, and formatting preferences for calendars. These affect how dates and times are displayed throughout Kithkin.',
|
||||||
],
|
],
|
||||||
|
'my_calendars' => 'Settings for my calendars',
|
||||||
'subscribe' => [
|
'subscribe' => [
|
||||||
'title' => 'Subscribe to a calendar',
|
'title' => 'Subscribe to a calendar',
|
||||||
'subtitle' => 'Add an `.ics` calendar from another service',
|
'subtitle' => 'Add an `.ics` calendar from another service',
|
||||||
],
|
],
|
||||||
|
'saved' => 'Your calendar settings have been saved!',
|
||||||
'title' => 'Calendar settings',
|
'title' => 'Calendar settings',
|
||||||
],
|
],
|
||||||
|
'timezone_help' => 'You can override your default time zone here.'
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -22,5 +22,7 @@ return [
|
|||||||
'password' => 'Password',
|
'password' => 'Password',
|
||||||
'save_changes' => 'Save changes',
|
'save_changes' => 'Save changes',
|
||||||
'settings' => 'Settings',
|
'settings' => 'Settings',
|
||||||
|
'timezone' => 'Time zone',
|
||||||
|
'timezone_select' => 'Select a time zone',
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -146,6 +146,10 @@ main {
|
|||||||
@apply bg-cyan-500;
|
@apply bg-cyan-500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
@apply flex items-center flex-row gap-2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,6 +201,9 @@ main {
|
|||||||
/* page subtitle section */
|
/* page subtitle section */
|
||||||
.description {
|
.description {
|
||||||
|
|
||||||
|
strong {
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* everything below the description (i.e., the content pane) */
|
/* everything below the description (i.e., the content pane) */
|
||||||
|
|||||||
@ -39,4 +39,8 @@ details {
|
|||||||
> .content {
|
> .content {
|
||||||
@apply mt-2;
|
@apply mt-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> ul.content {
|
||||||
|
@apply flex flex-col gap-2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,6 +73,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* calendar list in the left bar */
|
||||||
|
#calendar-toggles {
|
||||||
|
|
||||||
|
/* show menu on hover */
|
||||||
|
li:hover {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* limit calendar titles to 1 line */
|
||||||
|
.checkbox-label {
|
||||||
|
> span {
|
||||||
|
@apply line-clamp-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* animations */
|
/* animations */
|
||||||
@keyframes event-slide {
|
@keyframes event-slide {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@ -6,7 +6,8 @@ input[type="password"],
|
|||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="url"],
|
input[type="url"],
|
||||||
input[type="search"],
|
input[type="search"],
|
||||||
select {
|
select,
|
||||||
|
textarea {
|
||||||
@apply border-md border-gray-800 bg-white rounded-md shadow-input;
|
@apply border-md border-gray-800 bg-white rounded-md shadow-input;
|
||||||
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
|
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
|
||||||
transition: box-shadow 125ms ease-in-out,
|
transition: box-shadow 125ms ease-in-out,
|
||||||
|
|||||||
1
resources/svg/icons/circle-small.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"><circle cx="12" cy="12" r="8"/></svg>
|
||||||
|
After Width: | Height: | Size: 219 B |
1
resources/svg/icons/circle.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"><circle cx="12" cy="12" r="10"/></svg>
|
||||||
|
After Width: | Height: | Size: 220 B |
1
resources/svg/icons/share.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"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/></svg>
|
||||||
|
After Width: | Height: | Size: 378 B |
@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round" viewBox="0 0 24 24"><circle cx="11" cy="13" r="9" style="stroke:#000;stroke-width:2px"/><path d="M14.35 4.65 16.3 2.7a2.422 2.422 0 0 1 3.4 0l1.6 1.6a2.399 2.399 0 0 1 0 3.4l-1.95 1.95M22 2l-1.5 1.5" style="fill:none;fill-rule:nonzero;stroke:#000;stroke-width:2px"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="13" r="9" /><path d="M14.35 4.65 16.3 2.7a2.422 2.422 0 0 1 3.4 0l1.6 1.6a2.399 2.399 0 0 1 0 3.4l-1.95 1.95M22 2l-1.5 1.5" fill="none" /></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 420 B After Width: | Height: | Size: 354 B |
@ -1 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M7,5l-2,0c-0.549,0 -1,0.451 -1,1l0,3l3.005,0c0.549,0 0.995,0.446 0.995,0.995l0,6.505c0,1.844 0.667,3.533 1.772,4.839c0.169,0.176 0.272,0.415 0.272,0.679c0,0.542 -0.44,0.982 -0.982,0.982l-4.062,-0c-1.646,0 -3,-1.354 -3,-3l0,-14c0,-1.646 1.354,-3 3,-3l2,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l6,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l2,0c1.646,0 3,1.354 3,3l0,2.549c0,0.549 -0.446,0.995 -0.995,0.995c-0.545,0 -0.989,-0.44 -0.996,-0.983l-0,-0.012l-0.009,-2.549c0,-0.549 -0.451,-1 -1,-1l-2,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1l-6,0l-0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1Zm3,9l-0,-4c0,-0.552 0.448,-1 1,-1c0.552,0 1,0.448 1,1l-0,1.528c1.098,-0.982 2.522,-1.528 4,-1.528c2.332,0 4.461,1.359 5.442,3.474c0.232,0.501 0.015,1.096 -0.486,1.328c-0.501,0.232 -1.096,0.015 -1.328,-0.486c-0.654,-1.41 -2.074,-2.316 -3.628,-2.316c-0.976,0 -1.917,0.357 -2.646,1l1.646,-0c0.552,-0 1,0.448 1,1c0,0.552 -0.448,1 -1,1l-4,-0c-0.258,0 -0.505,-0.099 -0.691,-0.277c-0.198,-0.189 -0.309,-0.449 -0.309,-0.723Zm10,6.472c-1.098,0.982 -2.522,1.528 -4,1.528c-2.332,0 -4.461,-1.359 -5.442,-3.474c-0.232,-0.501 -0.015,-1.096 0.486,-1.328c0.501,-0.232 1.096,-0.015 1.328,0.486c0.654,1.41 2.074,2.316 3.628,2.316c0.976,0 1.917,-0.357 2.646,-1l-1.646,0c-0.552,0 -1,-0.448 -1,-1c-0,-0.552 0.448,-1 1,-1l4,0c0.258,-0 0.505,0.099 0.691,0.277c0.198,0.189 0.309,0.449 0.309,0.723l0,4c0,0.552 -0.448,1 -1,1c-0.552,0 -1,-0.448 -1,-1l0,-1.528Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7,5l-2,0c-0.549,0 -1,0.451 -1,1l0,3l3.005,0c0.549,0 0.995,0.446 0.995,0.995l0,6.505c0,1.844 0.667,3.533 1.772,4.839c0.169,0.176 0.272,0.415 0.272,0.679c0,0.542 -0.44,0.982 -0.982,0.982l-4.062,-0c-1.646,0 -3,-1.354 -3,-3l0,-14c0,-1.646 1.354,-3 3,-3l2,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l6,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l2,0c1.646,0 3,1.354 3,3l0,2.549c0,0.549 -0.446,0.995 -0.995,0.995c-0.545,0 -0.989,-0.44 -0.996,-0.983l-0,-0.012l-0.009,-2.549c0,-0.549 -0.451,-1 -1,-1l-2,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1l-6,0l-0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1Zm3,9l-0,-4c0,-0.552 0.448,-1 1,-1c0.552,0 1,0.448 1,1l-0,1.528c1.098,-0.982 2.522,-1.528 4,-1.528c2.332,0 4.461,1.359 5.442,3.474c0.232,0.501 0.015,1.096 -0.486,1.328c-0.501,0.232 -1.096,0.015 -1.328,-0.486c-0.654,-1.41 -2.074,-2.316 -3.628,-2.316c-0.976,0 -1.917,0.357 -2.646,1l1.646,-0c0.552,-0 1,0.448 1,1c0,0.552 -0.448,1 -1,1l-4,-0c-0.258,0 -0.505,-0.099 -0.691,-0.277c-0.198,-0.189 -0.309,-0.449 -0.309,-0.723Zm10,6.472c-1.098,0.982 -2.522,1.528 -4,1.528c-2.332,0 -4.461,-1.359 -5.442,-3.474c-0.232,-0.501 -0.015,-1.096 0.486,-1.328c0.501,-0.232 1.096,-0.015 1.328,0.486c0.654,1.41 2.074,2.316 3.628,2.316c0.976,0 1.917,-0.357 2.646,-1l-1.646,0c-0.552,0 -1,-0.448 -1,-1c-0,-0.552 0.448,-1 1,-1l4,0c0.258,-0 0.505,0.099 0.691,0.277c0.198,0.189 0.309,0.449 0.309,0.723l0,4c0,0.552 -0.448,1 -1,1c-0.552,0 -1,-0.448 -1,-1l0,-1.528Z"/></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.6 KiB |
@ -1 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M17,3l2,0c1.656,0 3,1.344 3,3l0,14c0,1.656 -1.344,3 -3,3l-14,0c-1.656,0 -3,-1.344 -3,-3l0,-14c0,-1.656 1.344,-3 3,-3l2,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l6,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1Zm-10,2l-2,0c-0.552,0 -1,0.448 -1,1l0,3l16,0l0,-3c0,-0.552 -0.448,-1 -1,-1l-2,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1l-6,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1Zm9.01,11.981c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-4.01,-4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-3.99,0c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm3.99,4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm4.01,-4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-8,4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17,3l2,0c1.656,0 3,1.344 3,3l0,14c0,1.656 -1.344,3 -3,3l-14,0c-1.656,0 -3,-1.344 -3,-3l0,-14c0,-1.656 1.344,-3 3,-3l2,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l6,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1Zm-10,2l-2,0c-0.552,0 -1,0.448 -1,1l0,3l16,0l0,-3c0,-0.552 -0.448,-1 -1,-1l-2,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1l-6,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1Zm9.01,11.981c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-4.01,-4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-3.99,0c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm3.99,4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm4.01,-4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-8,4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Z"/></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.5 KiB |
1
resources/svg/icons/solid/circle-small.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/></svg>
|
||||||
|
After Width: | Height: | Size: 227 B |
1
resources/svg/icons/solid/circle.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /></svg>
|
||||||
|
After Width: | Height: | Size: 229 B |
@ -1 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M11.546,22.991c-5.527,-0.225 -10.008,-4.536 -10.501,-9.991l6.012,0c0.221,3.467 1.59,6.872 4.108,9.572l0.38,0.418Zm1.144,-0.267l0.035,-0.034c2.589,-2.718 3.995,-6.172 4.219,-9.69l6.012,0c-0.493,5.457 -4.978,9.77 -10.509,9.991l0.243,-0.267Zm-0.27,-21.716c5.543,0.208 10.042,4.526 10.535,9.992l-6.012,-0c-0.224,-3.518 -1.63,-6.971 -4.219,-9.69l-0.034,-0.034l-0.026,-0.024l-0.244,-0.244Zm-1.144,0.302c-2.589,2.718 -3.995,6.172 -4.219,9.69l-6.012,-0c0.493,-5.456 4.976,-9.768 10.505,-9.991l-0.274,0.301Zm0.724,2.205c1.766,2.187 2.746,4.812 2.942,7.485l-5.883,-0c0.195,-2.673 1.176,-5.298 2.942,-7.485Zm0,16.97c-1.766,-2.187 -2.746,-4.812 -2.942,-7.485l5.883,-0c-0.195,2.673 -1.176,5.298 -2.942,7.485Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11.546,22.991c-5.527,-0.225 -10.008,-4.536 -10.501,-9.991l6.012,0c0.221,3.467 1.59,6.872 4.108,9.572l0.38,0.418Zm1.144,-0.267l0.035,-0.034c2.589,-2.718 3.995,-6.172 4.219,-9.69l6.012,0c-0.493,5.457 -4.978,9.77 -10.509,9.991l0.243,-0.267Zm-0.27,-21.716c5.543,0.208 10.042,4.526 10.535,9.992l-6.012,-0c-0.224,-3.518 -1.63,-6.971 -4.219,-9.69l-0.034,-0.034l-0.026,-0.024l-0.244,-0.244Zm-1.144,0.302c-2.589,2.718 -3.995,6.172 -4.219,9.69l-6.012,-0c0.493,-5.456 4.976,-9.768 10.505,-9.991l-0.274,0.301Zm0.724,2.205c1.766,2.187 2.746,4.812 2.942,7.485l-5.883,-0c0.195,-2.673 1.176,-5.298 2.942,-7.485Zm0,16.97c-1.766,-2.187 -2.746,-4.812 -2.942,-7.485l5.883,-0c-0.195,2.673 -1.176,5.298 -2.942,7.485Z"/></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 895 B |
@ -1 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M12,1c6.071,0 11,4.929 11,11c0,6.071 -4.929,11 -11,11c-6.071,0 -11,-4.929 -11,-11c0,-6.071 4.929,-11 11,-11Zm1.25,15l0,-4c-0,-0.69 -0.56,-1.25 -1.25,-1.25c-0.69,0 -1.25,0.56 -1.25,1.25l0,4c-0,0.69 0.56,1.25 1.25,1.25c0.69,0 1.25,-0.56 1.25,-1.25Zm-1.252,-9.252c-0.691,0 -1.252,0.561 -1.252,1.252c0,0.691 0.561,1.252 1.252,1.252c0.691,0 1.252,-0.561 1.252,-1.252c0,-0.691 -0.561,-1.252 -1.252,-1.252Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12,1c6.071,0 11,4.929 11,11c0,6.071 -4.929,11 -11,11c-6.071,0 -11,-4.929 -11,-11c0,-6.071 4.929,-11 11,-11Zm1.25,15l0,-4c-0,-0.69 -0.56,-1.25 -1.25,-1.25c-0.69,0 -1.25,0.56 -1.25,1.25l0,4c-0,0.69 0.56,1.25 1.25,1.25c0.69,0 1.25,-0.56 1.25,-1.25Zm-1.252,-9.252c-0.691,0 -1.252,0.561 -1.252,1.252c0,0.691 0.561,1.252 1.252,1.252c0.691,0 1.252,-0.561 1.252,-1.252c0,-0.691 -0.561,-1.252 -1.252,-1.252Z" stroke="none" /></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 857 B After Width: | Height: | Size: 622 B |
@ -1 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M11.172,17l-0.172,0l0,1c0,1.097 -0.903,2 -2,2l-1,0l0,1c0,1.097 -0.903,2 -2,2l-3,0c-1.097,0 -2,-0.903 -2,-2l0,-2.172c0,-0.795 0.317,-1.559 0.879,-2.121l6.392,-6.392c-0.154,-0.605 -0.233,-1.228 -0.233,-1.853c0,-4.114 3.386,-7.5 7.5,-7.5c4.114,0 7.5,3.386 7.5,7.5c0,4.114 -3.386,7.5 -7.5,7.5c-0.626,0 -1.249,-0.078 -1.853,-0.233l-0.392,0.392c-0.562,0.562 -1.326,0.879 -2.121,0.879Zm5.311,-11.526c-1.109,0 -2.009,0.9 -2.009,2.009c0,1.109 0.9,2.009 2.009,2.009c1.109,0 2.009,-0.9 2.009,-2.009c0,-1.109 -0.9,-2.009 -2.009,-2.009Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.586,17.414l6.814,-6.814c-0.239,-0.687 -0.362,-1.41 -0.362,-2.138c0,-3.566 2.934,-6.5 6.5,-6.5c3.566,0 6.5,2.934 6.5,6.5c0,3.566 -2.934,6.5 -6.5,6.5c-0.728,0 -1.451,-0.122 -2.138,-0.362l-0.814,0.814c-0.375,0.375 -0.884,0.586 -1.414,0.586l-0.172,0c-0.549,0 -1,0.451 -1,1l0,1c0,0.549 -0.451,1 -1,1l-1,0c-0.549,0 -1,0.451 -1,1l0,1c0,0.549 -0.451,1 -1,1l-3,0c-0.549,0 -1,-0.451 -1,-1l0,-2.172c0,-0.53 0.211,-1.039 0.586,-1.414Zm13.914,-12.414c-1.38,0 -2.5,1.12 -2.5,2.5c0,1.38 1.12,2.5 2.5,2.5c1.38,0 2.5,-1.12 2.5,-2.5c0,-1.38 -1.12,-2.5 -2.5,-2.5Z" /></svg>
|
||||||
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 755 B |
@ -1 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;"><path d="M20,10c0,4.993 -5.539,10.193 -7.399,11.799c-0.355,0.267 -0.847,0.267 -1.202,0c-1.86,-1.606 -7.399,-6.806 -7.399,-11.799c-0,-4.389 3.611,-8 8,-8c4.389,-0 8,3.611 8,8Zm-8,-3c-1.656,0 -3,1.344 -3,3c0,1.656 1.344,3 3,3c1.656,0 3,-1.344 3,-3c0,-1.656 -1.344,-3 -3,-3Z" style="stroke:#000;stroke-width:2px;"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20,10c0,4.993 -5.539,10.193 -7.399,11.799c-0.355,0.267 -0.847,0.267 -1.202,0c-1.86,-1.606 -7.399,-6.806 -7.399,-11.799c-0,-4.389 3.611,-8 8,-8c4.389,-0 8,3.611 8,8Zm-8,-3c-1.656,0 -3,1.344 -3,3c0,1.656 1.344,3 3,3c1.656,0 3,-1.344 3,-3c0,-1.656 -1.344,-3 -3,-3Z" /></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 759 B After Width: | Height: | Size: 471 B |
1
resources/svg/icons/solid/share.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"><circle cx="18" cy="5" r="3" fill="currentColor" /><circle cx="6" cy="12" r="3" fill="currentColor" /><circle cx="18" cy="19" r="3" fill="currentColor" /><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/></svg>
|
||||||
|
After Width: | Height: | Size: 441 B |
@ -109,7 +109,7 @@
|
|||||||
{{-- save --}}
|
{{-- save --}}
|
||||||
<div class="input-row input-row--actions input-row--start sticky-bottom">
|
<div class="input-row input-row--actions input-row--start sticky-bottom">
|
||||||
<x-button type="submit" variant="primary">{{ __('common.save_changes') }}</x-button>
|
<x-button type="submit" variant="primary">{{ __('common.save_changes') }}</x-button>
|
||||||
<x-button type="anchor" variant="secondary" href="{{ route('account.addresses') }}">{{ __('common.cancel') }}</x-button>
|
<x-button type="anchor" variant="tertiary" href="{{ route('account.addresses') }}">{{ __('common.cancel') }}</x-button>
|
||||||
|
|
||||||
@if (session('status') === 'addresses-updated')
|
@if (session('status') === 'addresses-updated')
|
||||||
<p
|
<p
|
||||||
|
|||||||
101
resources/views/calendar/settings/calendar.blade.php
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
{!! __('calendar.settings.calendar.subtitle', ['calendar' => $data['instance']['displayname']]) !!}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
/** @var \App\Models\CalendarInstance $instance */
|
||||||
|
$instance = $data['instance'];
|
||||||
|
$meta = $data['meta'] ?? null;
|
||||||
|
|
||||||
|
$isRemote = (bool) ($meta?->is_remote ?? false);
|
||||||
|
|
||||||
|
$color = old('color', $instance->resolvedColor());
|
||||||
|
|
||||||
|
$timezone = old('timezone',
|
||||||
|
$instance->timezone
|
||||||
|
?? ($data['userTz'] ?? 'UTC')
|
||||||
|
);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<form method="post"
|
||||||
|
action="{{ route('calendar.settings.calendars.update', $instance->uri) }}"
|
||||||
|
class="settings mt-8">
|
||||||
|
@csrf
|
||||||
|
@method('patch')
|
||||||
|
|
||||||
|
<div class="input-row input-row--1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.label for="displayname" :value="__('calendar.name')" />
|
||||||
|
<x-input.text id="displayname"
|
||||||
|
name="displayname"
|
||||||
|
type="text"
|
||||||
|
required="true"
|
||||||
|
:value="old('displayname', $instance->displayname)" />
|
||||||
|
<x-input.error :messages="$errors->get('displayname')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-row input-row--1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.textarea-label
|
||||||
|
:label="__('calendar.description')"
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Brief description for this calendar..."
|
||||||
|
:value="old('description', $instance->description)"
|
||||||
|
/>
|
||||||
|
<x-input.error :messages="$errors->get('description')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-row input-row--1-1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.label for="timezone" :value="__('common.timezone')" />
|
||||||
|
<x-input.select
|
||||||
|
id="timezone"
|
||||||
|
name="timezone"
|
||||||
|
placeholder="{{ __('common.timezone_select') }}"
|
||||||
|
:value="$timezone"
|
||||||
|
:options="$timezones"
|
||||||
|
:selected="old('timezone', $instance->timezone ?? $user->timezone)"
|
||||||
|
:description="__('calendar.timezone_help')" />
|
||||||
|
<x-input.error :messages="$errors->get('timezone')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.label for="color" :value="__('calendar.color')" />
|
||||||
|
<x-input.text id="color"
|
||||||
|
name="color"
|
||||||
|
type="color"
|
||||||
|
:value="$color" />
|
||||||
|
<x-input.error :messages="$errors->get('color')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($isRemote)
|
||||||
|
<div class="input-row input-row--1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.text-label
|
||||||
|
:label="__('calendar.ics.url')"
|
||||||
|
id="ics_url"
|
||||||
|
name="ics_url"
|
||||||
|
type="url"
|
||||||
|
:value="$data['icsUrl'] ?? ''"
|
||||||
|
disabled="true"
|
||||||
|
:description="__('calendar.settings.calendar.ics_url_help')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="input-row input-row--actions input-row--start sticky-bottom">
|
||||||
|
<x-button variant="primary" type="submit">{{ __('common.save_changes') }}</x-button>
|
||||||
|
<x-button type="anchor"
|
||||||
|
variant="tertiary"
|
||||||
|
href="{{ route('calendar.settings') }}">{{ __('common.cancel') }}</x-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@ -4,7 +4,7 @@
|
|||||||
<h1>
|
<h1>
|
||||||
{{ __('common.calendar') }}
|
{{ __('common.calendar') }}
|
||||||
</h1>
|
</h1>
|
||||||
<x-menu.calendar-settings />
|
<x-menu.calendar-settings :calendars="$calendars" />
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="header">
|
<x-slot name="header">
|
||||||
@ -20,7 +20,7 @@
|
|||||||
<x-slot name="article">
|
<x-slot name="article">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@isset($view)
|
@isset($view)
|
||||||
@include($view, $data ?? [])
|
@include($view)
|
||||||
@else
|
@else
|
||||||
<p class="text-muted">{{ __('Pick an option in the sidebar…') }}</p>
|
<p class="text-muted">{{ __('Pick an option in the sidebar…') }}</p>
|
||||||
@endisset
|
@endisset
|
||||||
|
|||||||
@ -5,33 +5,44 @@
|
|||||||
It's possible (and likely) that some of the things you can add to your calendar here will not work in other calendar apps or services. You'll always see that data here, though.
|
It's possible (and likely) that some of the things you can add to your calendar here will not work in other calendar apps or services. You'll always see that data here, though.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form method="post"
|
<form method="post" action="{{ route('subscriptions.store') }}" class="settings">
|
||||||
action="{{ route('subscriptions.store') }}"
|
|
||||||
class="form-grid-1 mt-8">
|
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<x-input.text-label
|
<div class="input-row input-row--1">
|
||||||
name="source"
|
<div class="input-cell">
|
||||||
type="url"
|
<x-input.text-label
|
||||||
label="{{ __('Calendar URL (ICS)') }}"
|
name="source"
|
||||||
placeholder="https://ical.mac.com/ical/MoonPhases.ics"
|
type="url"
|
||||||
required="true" />
|
label="{{ __('Calendar URL (ICS)') }}"
|
||||||
<x-input.text-label
|
placeholder="https://ical.mac.com/ical/MoonPhases.ics"
|
||||||
name="displayname"
|
required="true"
|
||||||
type="text"
|
/>
|
||||||
label="{{ __('Display name') }}"
|
</div>
|
||||||
placeholder="Phases of the moon..."
|
</div>
|
||||||
required="true"
|
<div class="input-row input-row--1">
|
||||||
description="If you leave this blank, we'll try to make a best guess for the name." />
|
<div class="input-cell">
|
||||||
<x-input.text-label
|
<x-input.text-label
|
||||||
name="color"
|
name="displayname"
|
||||||
type="color"
|
type="text"
|
||||||
value="#06D2A1"
|
label="{{ __('Display name') }}"
|
||||||
label="{{ __('Calendar color') }}" />
|
placeholder="Phases of the moon..."
|
||||||
|
required="true"
|
||||||
<div class="flex gap-4">
|
description="If you leave this blank, we'll try to make a best guess for the name."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-row input-row--1">
|
||||||
|
<div class="input-cell">
|
||||||
|
<x-input.text-label
|
||||||
|
name="color"
|
||||||
|
type="color"
|
||||||
|
value="#06D2A1"
|
||||||
|
label="{{ __('Calendar color') }}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-row input-row--actions input-row--start sticky-bottom">
|
||||||
<x-button variant="primary" type="submit">{{ __('Subscribe') }}</x-button>
|
<x-button variant="primary" type="submit">{{ __('Subscribe') }}</x-button>
|
||||||
<a href="{{ route('calendar.index') }}"
|
<x-button type="anchor" variant="tertiary" href="{{ route('calendar.settings.subscribe') }}">{{ __('common.cancel') }}</x-button>
|
||||||
class="button button--secondary">{{ __('Cancel and go back') }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
44
resources/views/components/app/calendarlink.blade.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
@props([
|
||||||
|
'active' => false, // boolean whether the tab is active
|
||||||
|
'label' => null, // tab label
|
||||||
|
'icon' => null, // icon name only
|
||||||
|
'color' => '', // optional icon color
|
||||||
|
'remote' => null // flag for whether calendar is remote
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$isActive = (bool) $active;
|
||||||
|
|
||||||
|
$classes = trim(collect([
|
||||||
|
'pagelink',
|
||||||
|
$isActive ? 'is-active' : null,
|
||||||
|
])->filter()->implode(' '));
|
||||||
|
|
||||||
|
$iconComponent = null;
|
||||||
|
if ($icon) {
|
||||||
|
$iconComponent = $isActive
|
||||||
|
? 'icon-solid.'.$icon
|
||||||
|
: 'icon-'.$icon;
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||||
|
@if ($iconComponent)
|
||||||
|
<x-dynamic-component :component="$iconComponent" width="20" :color="$color" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (!is_null($label))
|
||||||
|
<span>
|
||||||
|
{{ $label }}
|
||||||
|
@if ($remote)
|
||||||
|
@if ($isActive)
|
||||||
|
<x-icon-solid.share width="12" />
|
||||||
|
@else
|
||||||
|
<x-icon-share width="12" />
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
{{ $slot }}
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
@ -1,11 +1,8 @@
|
|||||||
@props([
|
@props([
|
||||||
'active' => false,
|
'active' => false, // boolean whether the tab is active
|
||||||
|
'label' => null, // tab label
|
||||||
// label text (string)
|
'icon' => null, // icon name only
|
||||||
'label' => null,
|
'color' => '', // optional icon color
|
||||||
|
|
||||||
// icon name only
|
|
||||||
'icon' => null,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@ -26,7 +23,7 @@
|
|||||||
|
|
||||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||||
@if ($iconComponent)
|
@if ($iconComponent)
|
||||||
<x-dynamic-component :component="$iconComponent" width="20" />
|
<x-dynamic-component :component="$iconComponent" width="20" :color="$color" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (!is_null($label))
|
@if (!is_null($label))
|
||||||
|
|||||||
@ -44,10 +44,15 @@
|
|||||||
|
|
||||||
@foreach($options as $key => $opt)
|
@foreach($options as $key => $opt)
|
||||||
{{-- optgroup: 'Group label' => [ ...options... ] --}}
|
{{-- optgroup: 'Group label' => [ ...options... ] --}}
|
||||||
@if(is_array($opt) && !array_key_exists('value', $opt) && !array_key_exists('label', $opt))
|
@if(is_array($opt) && is_string($key))
|
||||||
<optgroup label="{{ $key }}">
|
<optgroup label="{{ $key }}">
|
||||||
@foreach($opt as $value => $label)
|
@foreach($opt as $value => $label)
|
||||||
{!! $renderOption($value, $label) !!}
|
{{-- allow rich options inside optgroups too --}}
|
||||||
|
@if(is_array($label) && array_key_exists('value', $label))
|
||||||
|
{!! $renderOption($label['value'], $label['label'] ?? $label['value'], $label['attrs'] ?? []) !!}
|
||||||
|
@else
|
||||||
|
{!! $renderOption($value, $label) !!}
|
||||||
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
'inputclass' => '', // input classes
|
'inputclass' => '', // input classes
|
||||||
'name' => '', // input name
|
'name' => '', // input name
|
||||||
'type' => 'text', // input type (text, url, etc)
|
'type' => 'text', // input type (text, url, etc)
|
||||||
|
'disabled' => false, // disabled flag
|
||||||
'value' => '', // input value
|
'value' => '', // input value
|
||||||
'placeholder' => '', // placeholder text
|
'placeholder' => '', // placeholder text
|
||||||
'style' => '', // raw style string for the input
|
'style' => '', // raw style string for the input
|
||||||
@ -21,6 +22,7 @@
|
|||||||
:placeholder="$placeholder"
|
:placeholder="$placeholder"
|
||||||
:style="$style"
|
:style="$style"
|
||||||
:required="$required"
|
:required="$required"
|
||||||
|
:disabled="$disabled"
|
||||||
/>
|
/>
|
||||||
@if($description !== '')<span class="description">{{ $description}}</span>@endif
|
@if($description !== '')<span class="description">{{ $description}}</span>@endif
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
'type' => 'text', // input type
|
'type' => 'text', // input type
|
||||||
'name' => '', // input name
|
'name' => '', // input name
|
||||||
'value' => '', // input value
|
'value' => '', // input value
|
||||||
|
'disabled' => false, // disabled flag
|
||||||
'placeholder' => '', // placeholder text
|
'placeholder' => '', // placeholder text
|
||||||
'style' => '', // raw style string
|
'style' => '', // raw style string
|
||||||
'required' => false, // true/false or truthy value
|
'required' => false, // true/false or truthy value
|
||||||
@ -14,4 +15,5 @@
|
|||||||
placeholder="{{ $placeholder }}"
|
placeholder="{{ $placeholder }}"
|
||||||
{{ $attributes->merge(['class' => 'text']) }}
|
{{ $attributes->merge(['class' => 'text']) }}
|
||||||
@if($style !== '') style="{{ $style }}" @endif
|
@if($style !== '') style="{{ $style }}" @endif
|
||||||
@required($required) />
|
@required($required)
|
||||||
|
@disabled($disabled) />
|
||||||
|
|||||||
26
resources/views/components/input/textarea-label.blade.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
@props([
|
||||||
|
'label' => '', // label text
|
||||||
|
'labelclass' => '', // extra CSS classes for the label
|
||||||
|
'inputclass' => '', // input classes
|
||||||
|
'name' => '', // input name
|
||||||
|
'disabled' => false, // disabled flag
|
||||||
|
'value' => '', // input value
|
||||||
|
'placeholder' => '', // placeholder text
|
||||||
|
'style' => '', // raw style string for the input
|
||||||
|
'required' => false, // true/false or truthy value
|
||||||
|
'description' => '', // optional descriptive text below the input
|
||||||
|
])
|
||||||
|
|
||||||
|
<label {{ $attributes->class("text-label $labelclass") }}>
|
||||||
|
<span class="label">{{ $label }}</span>
|
||||||
|
<x-input.textarea
|
||||||
|
:name="$name"
|
||||||
|
:value="$value"
|
||||||
|
:class="$inputclass"
|
||||||
|
:placeholder="$placeholder"
|
||||||
|
:style="$style"
|
||||||
|
:required="$required"
|
||||||
|
:disabled="$disabled"
|
||||||
|
/>
|
||||||
|
@if($description !== '')<span class="description">{{ $description}}</span>@endif
|
||||||
|
</label>
|
||||||
18
resources/views/components/input/textarea.blade.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
@props([
|
||||||
|
'class' => '', // extra CSS classes
|
||||||
|
'name' => '', // input name
|
||||||
|
'value' => '', // input value
|
||||||
|
'disabled' => false, // disabled flag
|
||||||
|
'placeholder' => '', // placeholder text
|
||||||
|
'style' => '', // raw style string
|
||||||
|
'required' => false, // true/false or truthy value
|
||||||
|
])
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
name="{{ $name }}"
|
||||||
|
placeholder="{{ $placeholder }}"
|
||||||
|
{{ $attributes->merge(['class' => 'text']) }}
|
||||||
|
@if($style !== '') style="{{ $style }}" @endif
|
||||||
|
@required($required)
|
||||||
|
@disabled($disabled)
|
||||||
|
>{{ $value }}</textarea>
|
||||||
@ -25,4 +25,21 @@
|
|||||||
</li>
|
</li>
|
||||||
</menu>
|
</menu>
|
||||||
</details>
|
</details>
|
||||||
|
<details open>
|
||||||
|
<summary>{{ __('calendar.settings.my_calendars') }}</summary>
|
||||||
|
<menu class="content pagelinks">
|
||||||
|
@foreach ($calendars as $cal)
|
||||||
|
<li>
|
||||||
|
<x-app.calendarlink
|
||||||
|
href="{{ route('calendar.settings.calendars.show', $cal->uri) }}"
|
||||||
|
:active="request()->routeIs('calendar.settings.calendars.show') && request()->route('calendarUri') === $cal->uri"
|
||||||
|
:label="$cal['displayname']"
|
||||||
|
icon="circle-small"
|
||||||
|
:color="$cal['calendarcolor']"
|
||||||
|
:remote="$cal['is_remote']"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</menu>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -93,6 +93,14 @@ Route::middleware('auth')->group(function ()
|
|||||||
Route::resource('subscriptions', SubscriptionController::class)
|
Route::resource('subscriptions', SubscriptionController::class)
|
||||||
->except(['show']); // index, create, store, edit, update, destroy
|
->except(['show']); // index, create, store, edit, update, destroy
|
||||||
|
|
||||||
|
// calendar settings for a specific calendar instance/container
|
||||||
|
Route::get('settings/calendars/{calendarUri}', [CalendarSettingsController::class, 'calendarForm'])
|
||||||
|
->whereUuid('calendarUri') // sabre calendarid is an int
|
||||||
|
->name('settings.calendars.show');
|
||||||
|
Route::patch('settings/calendars/{calendarUri}', [CalendarSettingsController::class, 'calendarStore'])
|
||||||
|
->whereUuid('calendarUri')
|
||||||
|
->name('settings.calendars.update');
|
||||||
|
|
||||||
// events
|
// events
|
||||||
Route::prefix('{calendar}')->whereUuid('calendar')->group(function () {
|
Route::prefix('{calendar}')->whereUuid('calendar')->group(function () {
|
||||||
// create & store
|
// create & store
|
||||||
|
|||||||