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
|
||||
$instance = CalendarInstance::create([
|
||||
'calendarid' => $calId,
|
||||
'principaluri' => auth()->user()->principal_uri,
|
||||
'principaluri' => auth()->user()->uri,
|
||||
'uri' => Str::uuid(),
|
||||
'displayname' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
@ -300,7 +300,7 @@ class CalendarController extends Controller
|
||||
|
||||
$calendar->load([
|
||||
'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 */
|
||||
@ -329,7 +329,7 @@ class CalendarController extends Controller
|
||||
$calendar->load([
|
||||
'meta',
|
||||
'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
|
||||
@ -353,7 +353,7 @@ class CalendarController extends Controller
|
||||
|
||||
// update the instance row
|
||||
$calendar->instances()
|
||||
->where('principaluri', auth()->user()->principal_uri)
|
||||
->where('principaluri', auth()->user()->uri)
|
||||
->update([
|
||||
'displayname' => $data['name'],
|
||||
'description' => $data['description'] ?? '',
|
||||
|
||||
@ -2,9 +2,14 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CalendarInstance;
|
||||
use App\Models\CalendarMeta;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
|
||||
class CalendarSettingsController extends Controller
|
||||
{
|
||||
@ -108,8 +113,8 @@ class CalendarSettingsController extends Controller
|
||||
return $this->frame(
|
||||
'calendar.settings.subscribe',
|
||||
[
|
||||
'title' => 'Subscribe to a calendar',
|
||||
'sub' => 'Add an `.ics` calender from another service'
|
||||
'title' => __('calendar.settings.subscribe.title'),
|
||||
'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
|
||||
*/
|
||||
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', [
|
||||
'view' => $view,
|
||||
'data' => $data,
|
||||
'view' => $view,
|
||||
'data' => $data,
|
||||
'calendars' => $calendars,
|
||||
'timezones' => config('timezones'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,22 +2,21 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Jobs\SyncSubscription;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Calendar;
|
||||
use App\Models\CalendarInstance;
|
||||
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
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$subs = Subscription::where(
|
||||
'principaluri',
|
||||
auth()->user()->principal_uri
|
||||
)->get();
|
||||
$subs = Subscription::where('principaluri', auth()->user()->uri)->get();
|
||||
|
||||
return view('subscription.index', compact('subs'));
|
||||
}
|
||||
@ -36,51 +35,91 @@ class SubscriptionController extends Controller
|
||||
'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([
|
||||
'uri' => Str::uuid(),
|
||||
'principaluri' => 'principals/'.$request->user()->email,
|
||||
'source' => $data['source'],
|
||||
'displayname' => $data['displayname'] ?: $data['source'],
|
||||
'uri' => (string) Str::uuid(),
|
||||
'principaluri' => $principalUri,
|
||||
'source' => $source,
|
||||
'displayname' => $data['displayname'] ?: $source,
|
||||
'calendarcolor' => $data['color'] ?? '#1a1a1a',
|
||||
'refreshrate' => 'P1D',
|
||||
'lastmodified' => now()->timestamp,
|
||||
]);
|
||||
|
||||
/* create the empty "shadow" calendar container */
|
||||
$calId = Calendar::create([
|
||||
'synctoken' => 1,
|
||||
'components' => 'VEVENT',
|
||||
])->id;
|
||||
// choose the calendar container
|
||||
if ($existingInstance) {
|
||||
$calId = $existingInstance->calendarid;
|
||||
|
||||
/* create the calendarinstance row attached to the user */
|
||||
CalendarInstance::create([
|
||||
'calendarid' => $calId,
|
||||
'principaluri' => $sub->principaluri,
|
||||
'uri' => Str::uuid(),
|
||||
'displayname' => $sub->displayname,
|
||||
'description' => 'Remote feed: '.$sub->source,
|
||||
'calendarcolor' => $sub->calendarcolor,
|
||||
'timezone' => config('app.timezone', 'UTC'),
|
||||
]);
|
||||
// keep the mirror instance’s user-facing bits up to date
|
||||
$existingInstance->update([
|
||||
'displayname' => $sub->displayname,
|
||||
'calendarcolor' => $sub->calendarcolor,
|
||||
'timezone' => config('app.timezone', 'UTC'),
|
||||
]);
|
||||
} else {
|
||||
// create new empty calendar container
|
||||
$calId = Calendar::create([
|
||||
'synctoken' => 1,
|
||||
'components' => 'VEVENT',
|
||||
])->id;
|
||||
|
||||
/* create our calendar_meta entry */
|
||||
CalendarMeta::create([
|
||||
'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,
|
||||
]);
|
||||
// create mirror calendarinstance row
|
||||
CalendarInstance::create([
|
||||
'calendarid' => $calId,
|
||||
'principaluri' => $sub->principaluri,
|
||||
'uri' => Str::uuid(),
|
||||
'displayname' => $sub->displayname,
|
||||
'description' => $desc,
|
||||
'calendarcolor' => $sub->calendarcolor,
|
||||
'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()
|
||||
->route('calendar.index')
|
||||
->with('toast', __('Subscription added!'));
|
||||
->with('toast', [
|
||||
'message' => __('Subscription added! Syncing events now...'),
|
||||
'type' => 'success',
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Subscription $subscription)
|
||||
|
||||
@ -12,83 +12,124 @@ use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Sabre\VObject\ParseException;
|
||||
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
|
||||
{
|
||||
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
|
||||
|
||||
/** @var \App\Models\Subscription */
|
||||
public Subscription $subscription;
|
||||
|
||||
/**
|
||||
* @param Subscription $subscription The feed to sync.
|
||||
*/
|
||||
public function __construct(Subscription $subscription)
|
||||
{
|
||||
$this->subscription = $subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry-point executed by the queue worker.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
/**
|
||||
* download the remote .ics file with retry and a long timeout */
|
||||
// normalize the source a bit so comparisons/logging are consistent
|
||||
$source = rtrim(trim((string) $this->subscription->source), '/');
|
||||
|
||||
// 1) download the remote feed
|
||||
try {
|
||||
$ics = Http::retry(3, 5000)->timeout(30)
|
||||
->withHeaders(['User-Agent' => 'Kithkin CalDAV Bot'])
|
||||
->get($this->subscription->source)
|
||||
->throw() // throws if not 2xx
|
||||
->body();
|
||||
} catch (ConnectionException | \Throwable $e) {
|
||||
$resp = Http::retry(3, 5000)
|
||||
->timeout(30)
|
||||
->withHeaders([
|
||||
'User-Agent' => 'Kithkin CalDAV Bot',
|
||||
'Accept' => 'text/calendar, text/plain;q=0.9, */*;q=0.8',
|
||||
])
|
||||
->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', [
|
||||
'sub' => $this->subscription->id,
|
||||
'msg' => $e->getMessage(),
|
||||
'sub' => $this->subscription->id,
|
||||
'url' => $source,
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
/* mark the job as failed and let Horizon / queue retry logic handle it */
|
||||
$this->fail($e);
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* get the mirror calendar, or lazy create it */
|
||||
$meta = $this->subscription->meta ?? $this->subscription->meta()->create();
|
||||
// 2) ensure we actually got an ICS payload, not HTML or other content
|
||||
if (! $this->looksLikeIcs($body))
|
||||
{
|
||||
$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) {
|
||||
$meta->calendar_id = $this->createMirrorCalendar($meta);
|
||||
$meta->calendar_id = $this->createMirrorCalendar($meta, $source);
|
||||
$meta->save();
|
||||
}
|
||||
|
||||
$calendarId = $meta->calendar_id;
|
||||
$calendarId = (int) $meta->calendar_id;
|
||||
|
||||
/**
|
||||
* parse and upsert vevents */
|
||||
$vcalendar = Reader::read($ics);
|
||||
// 4) parse the VCALENDAR
|
||||
try {
|
||||
$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;
|
||||
$blob = (string) $vevent->serialize();
|
||||
|
||||
/** @var Event $object */
|
||||
$object = Event::updateOrCreate(
|
||||
['uid' => $uid, 'calendarid' => $calendarId],
|
||||
[
|
||||
@ -101,60 +142,100 @@ class SyncSubscription implements ShouldQueue
|
||||
]
|
||||
);
|
||||
|
||||
$startUtc = Carbon::parse($vevent->DTSTART->getDateTime());
|
||||
$endUtc = isset($vevent->DTEND)
|
||||
? Carbon::parse($vevent->DTEND->getDateTime())
|
||||
: $startUtc;
|
||||
// sabre gives DateTime objects here; Carbon::instance is safest
|
||||
$start = Carbon::instance($dtStart->getDateTime())->utc();
|
||||
$end = isset($vevent->DTEND)
|
||||
? Carbon::instance($vevent->DTEND->getDateTime())->utc()
|
||||
: $start;
|
||||
|
||||
EventMeta::upsertForEvent($object->id, [
|
||||
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
|
||||
'description' => (string) ($vevent->DESCRIPTION ?? ''),
|
||||
'location' => (string) ($vevent->LOCATION ?? ''),
|
||||
'all_day' => $vevent->DTSTART->isFloating(),
|
||||
'start_at' => $startUtc->utc(),
|
||||
'end_at' => $endUtc->utc(),
|
||||
'all_day' => $dtStart->isFloating(),
|
||||
'start_at' => $start,
|
||||
'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
|
||||
* (for legacy subscriptions added before we moved creation to the controller).
|
||||
* Lazily builds the shadow calendar + instance when missing.
|
||||
*/
|
||||
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) {
|
||||
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)
|
||||
->where('description', 'Remote feed: '.$this->subscription->source)
|
||||
->where('description', $desc)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing->calendarid;
|
||||
return (int) $existing->calendarid;
|
||||
}
|
||||
|
||||
// otherwise create the new master calendar in `calendars`
|
||||
$calendar = Calendar::create([
|
||||
'synctoken' => 1,
|
||||
'components' => 'VEVENT',
|
||||
]);
|
||||
|
||||
// attach an instance for this user
|
||||
CalendarInstance::create([
|
||||
'calendarid' => $calendar->id,
|
||||
'principaluri' => $this->subscription->principaluri,
|
||||
'uri' => Str::uuid(),
|
||||
'uri' => (string) Str::uuid(),
|
||||
'displayname' => $this->subscription->displayname,
|
||||
'description' => 'Remote feed: '.$this->subscription->source,
|
||||
'description' => $desc,
|
||||
'calendarcolor' => $meta->color ?? '#1a1a1a',
|
||||
'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;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class CalendarInstance extends Model
|
||||
{
|
||||
@ -20,11 +22,73 @@ class CalendarInstance extends Model
|
||||
'timezone',
|
||||
];
|
||||
|
||||
protected $appends = ['is_remote'];
|
||||
|
||||
public function calendar(): BelongsTo
|
||||
{
|
||||
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
|
||||
{
|
||||
// e.g. https://kithkin.lan/dav/calendars/1/48f888f3-c5c5-…/
|
||||
|
||||
@ -3,16 +3,12 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\CalendarMeta;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Subscription extends Model
|
||||
{
|
||||
/** basic table mapping */
|
||||
protected $table = 'calendarsubscriptions';
|
||||
public $timestamps = false; // sabre table
|
||||
protected $table = 'calendarsubscriptions';
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'uri',
|
||||
@ -25,18 +21,25 @@ class Subscription extends Model
|
||||
'striptodos',
|
||||
'stripalarms',
|
||||
'stripattachments',
|
||||
|
||||
// sync fields
|
||||
'last_sync_status',
|
||||
'last_sync_error',
|
||||
'last_sync_at',
|
||||
'lastmodified',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'striptodos' => 'bool',
|
||||
'stripalarms' => 'bool',
|
||||
'stripattachments' => 'bool',
|
||||
|
||||
// sync fields
|
||||
'last_sync_at' => 'datetime',
|
||||
];
|
||||
|
||||
/** relationship to meta row */
|
||||
public function meta()
|
||||
{
|
||||
return $this->hasOne(\App\Models\CalendarMeta::class,
|
||||
'subscription_id');
|
||||
return $this->hasOne(CalendarMeta::class, '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 {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('calendarinstances', function (Blueprint $table) {
|
||||
$table->unique(
|
||||
['principaluri', 'description'],
|
||||
'uniq_user_feed_mirror'
|
||||
);
|
||||
// enforce "one subscription per feed per user"
|
||||
Schema::table('calendarsubscriptions', function (Blueprint $table) {
|
||||
$table->unique(['principaluri', 'source'], 'uniq_subscription_principal_source');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('calendarinstances', function (Blueprint $table) {
|
||||
$table->dropUnique('uniq_user_feed_mirror');
|
||||
Schema::table('calendarsubscriptions', function (Blueprint $table) {
|
||||
$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' => [
|
||||
'calendar' => [
|
||||
'title' => 'Calendar settings',
|
||||
'subtitle' => 'Details and settings for <strong>:calendar</strong>.'
|
||||
],
|
||||
'language_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.',
|
||||
],
|
||||
'my_calendars' => 'Settings for my calendars',
|
||||
'subscribe' => [
|
||||
'title' => 'Subscribe to a calendar',
|
||||
'subtitle' => 'Add an `.ics` calendar from another service',
|
||||
],
|
||||
'saved' => 'Your calendar settings have been saved!',
|
||||
'title' => 'Calendar settings',
|
||||
],
|
||||
'timezone_help' => 'You can override your default time zone here.'
|
||||
|
||||
];
|
||||
|
||||
@ -22,5 +22,7 @@ return [
|
||||
'password' => 'Password',
|
||||
'save_changes' => 'Save changes',
|
||||
'settings' => 'Settings',
|
||||
'timezone' => 'Time zone',
|
||||
'timezone_select' => 'Select a time zone',
|
||||
|
||||
];
|
||||
|
||||
@ -146,6 +146,10 @@ main {
|
||||
@apply bg-cyan-500;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
@apply flex items-center flex-row gap-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -197,6 +201,9 @@ main {
|
||||
/* page subtitle section */
|
||||
.description {
|
||||
|
||||
strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
/* everything below the description (i.e., the content pane) */
|
||||
|
||||
@ -39,4 +39,8 @@ details {
|
||||
> .content {
|
||||
@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 */
|
||||
@keyframes event-slide {
|
||||
from {
|
||||
|
||||
@ -6,7 +6,8 @@ input[type="password"],
|
||||
input[type="text"],
|
||||
input[type="url"],
|
||||
input[type="search"],
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
@apply border-md border-gray-800 bg-white rounded-md shadow-input;
|
||||
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
|
||||
transition: box-shadow 125ms ease-in-out,
|
||||
|
||||
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 --}}
|
||||
<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="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')
|
||||
<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>
|
||||
{{ __('common.calendar') }}
|
||||
</h1>
|
||||
<x-menu.calendar-settings />
|
||||
<x-menu.calendar-settings :calendars="$calendars" />
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="header">
|
||||
@ -20,7 +20,7 @@
|
||||
<x-slot name="article">
|
||||
<div class="content">
|
||||
@isset($view)
|
||||
@include($view, $data ?? [])
|
||||
@include($view)
|
||||
@else
|
||||
<p class="text-muted">{{ __('Pick an option in the sidebar…') }}</p>
|
||||
@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.
|
||||
</p>
|
||||
</div>
|
||||
<form method="post"
|
||||
action="{{ route('subscriptions.store') }}"
|
||||
class="form-grid-1 mt-8">
|
||||
<form method="post" action="{{ route('subscriptions.store') }}" class="settings">
|
||||
@csrf
|
||||
|
||||
<x-input.text-label
|
||||
name="source"
|
||||
type="url"
|
||||
label="{{ __('Calendar URL (ICS)') }}"
|
||||
placeholder="https://ical.mac.com/ical/MoonPhases.ics"
|
||||
required="true" />
|
||||
<x-input.text-label
|
||||
name="displayname"
|
||||
type="text"
|
||||
label="{{ __('Display name') }}"
|
||||
placeholder="Phases of the moon..."
|
||||
required="true"
|
||||
description="If you leave this blank, we'll try to make a best guess for the name." />
|
||||
<x-input.text-label
|
||||
name="color"
|
||||
type="color"
|
||||
value="#06D2A1"
|
||||
label="{{ __('Calendar color') }}" />
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<x-input.text-label
|
||||
name="source"
|
||||
type="url"
|
||||
label="{{ __('Calendar URL (ICS)') }}"
|
||||
placeholder="https://ical.mac.com/ical/MoonPhases.ics"
|
||||
required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<x-input.text-label
|
||||
name="displayname"
|
||||
type="text"
|
||||
label="{{ __('Display name') }}"
|
||||
placeholder="Phases of the moon..."
|
||||
required="true"
|
||||
description="If you leave this blank, we'll try to make a best guess for the name."
|
||||
/>
|
||||
</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>
|
||||
<a href="{{ route('calendar.index') }}"
|
||||
class="button button--secondary">{{ __('Cancel and go back') }}</a>
|
||||
<x-button type="anchor" variant="tertiary" href="{{ route('calendar.settings.subscribe') }}">{{ __('common.cancel') }}</x-button>
|
||||
</div>
|
||||
</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([
|
||||
'active' => false,
|
||||
|
||||
// label text (string)
|
||||
'label' => null,
|
||||
|
||||
// icon name only
|
||||
'icon' => null,
|
||||
'active' => false, // boolean whether the tab is active
|
||||
'label' => null, // tab label
|
||||
'icon' => null, // icon name only
|
||||
'color' => '', // optional icon color
|
||||
])
|
||||
|
||||
@php
|
||||
@ -26,7 +23,7 @@
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
@if ($iconComponent)
|
||||
<x-dynamic-component :component="$iconComponent" width="20" />
|
||||
<x-dynamic-component :component="$iconComponent" width="20" :color="$color" />
|
||||
@endif
|
||||
|
||||
@if (!is_null($label))
|
||||
|
||||
@ -44,10 +44,15 @@
|
||||
|
||||
@foreach($options as $key => $opt)
|
||||
{{-- 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 }}">
|
||||
@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
|
||||
</optgroup>
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
'inputclass' => '', // input classes
|
||||
'name' => '', // input name
|
||||
'type' => 'text', // input type (text, url, etc)
|
||||
'disabled' => false, // disabled flag
|
||||
'value' => '', // input value
|
||||
'placeholder' => '', // placeholder text
|
||||
'style' => '', // raw style string for the input
|
||||
@ -21,6 +22,7 @@
|
||||
:placeholder="$placeholder"
|
||||
:style="$style"
|
||||
:required="$required"
|
||||
:disabled="$disabled"
|
||||
/>
|
||||
@if($description !== '')<span class="description">{{ $description}}</span>@endif
|
||||
</label>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
'type' => 'text', // input type
|
||||
'name' => '', // input name
|
||||
'value' => '', // input value
|
||||
'disabled' => false, // disabled flag
|
||||
'placeholder' => '', // placeholder text
|
||||
'style' => '', // raw style string
|
||||
'required' => false, // true/false or truthy value
|
||||
@ -14,4 +15,5 @@
|
||||
placeholder="{{ $placeholder }}"
|
||||
{{ $attributes->merge(['class' => 'text']) }}
|
||||
@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>
|
||||
</menu>
|
||||
</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>
|
||||
|
||||
@ -93,6 +93,14 @@ Route::middleware('auth')->group(function ()
|
||||
Route::resource('subscriptions', SubscriptionController::class)
|
||||
->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
|
||||
Route::prefix('{calendar}')->whereUuid('calendar')->group(function () {
|
||||
// create & store
|
||||
|
||||