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

This commit is contained in:
Andrew Gioia 2026-01-22 14:40:48 -05:00
parent 859d03ae30
commit da539d6146
Signed by: andrew
GPG Key ID: FC09694A000800C8
41 changed files with 927 additions and 169 deletions

View File

@ -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 shouldnt $instance = $calendar->instances->first(); // may be null but shouldnt
@ -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'] ?? '',

View File

@ -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'),
]); ]);
} }
} }

View File

@ -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, dont 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
if ($existingInstance) {
$calId = $existingInstance->calendarid;
// keep the mirror instances 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([ $calId = Calendar::create([
'synctoken' => 1, 'synctoken' => 1,
'components' => 'VEVENT', 'components' => 'VEVENT',
])->id; ])->id;
/* create the calendarinstance row attached to the user */ // create mirror calendarinstance row
CalendarInstance::create([ CalendarInstance::create([
'calendarid' => $calId, 'calendarid' => $calId,
'principaluri' => $sub->principaluri, 'principaluri' => $sub->principaluri,
'uri' => Str::uuid(), 'uri' => Str::uuid(),
'displayname' => $sub->displayname, 'displayname' => $sub->displayname,
'description' => 'Remote feed: '.$sub->source, 'description' => $desc,
'calendarcolor' => $sub->calendarcolor, 'calendarcolor' => $sub->calendarcolor,
'timezone' => config('app.timezone', 'UTC'), 'timezone' => config('app.timezone', 'UTC'),
]); ]);
}
/* create our calendar_meta entry */ // upsert our calendar_meta entry by calendar_id (since thats your pk)
CalendarMeta::create([ CalendarMeta::updateOrCreate(
'calendar_id' => $calId, ['calendar_id' => $calId],
[
'subscription_id' => $sub->id, 'subscription_id' => $sub->id,
'title' => $sub->displayname, 'title' => $sub->displayname,
'color' => $sub->calendarcolor, 'color' => $sub->calendarcolor,
'color_fg' => contrast_text_color($sub->calendarcolor), 'color_fg' => contrast_text_color($sub->calendarcolor),
'is_shared' => true, 'is_shared' => true,
'is_remote' => 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)

View File

@ -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,
'url' => $source,
'msg' => $e->getMessage(), '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();
} }
} }

View File

@ -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 instances 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-…/

View File

@ -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; // sabre table public $timestamps = false;
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
View 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)',
],
];

View File

@ -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');
}); });
} }
}; };

View File

@ -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',
]);
});
}
};

View File

@ -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.'
]; ];

View File

@ -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',
]; ];

View File

@ -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) */

View File

@ -39,4 +39,8 @@ details {
> .content { > .content {
@apply mt-2; @apply mt-2;
} }
> ul.content {
@apply flex flex-col gap-2;
}
} }

View File

@ -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 {

View File

@ -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,

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/></svg>

After

Width:  |  Height:  |  Size: 219 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg>

After

Width:  |  Height:  |  Size: 220 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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

View File

@ -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

View 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>

View File

@ -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

View File

@ -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
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.text-label <x-input.text-label
name="source" name="source"
type="url" type="url"
label="{{ __('Calendar URL (ICS)') }}" label="{{ __('Calendar URL (ICS)') }}"
placeholder="https://ical.mac.com/ical/MoonPhases.ics" placeholder="https://ical.mac.com/ical/MoonPhases.ics"
required="true" /> required="true"
/>
</div>
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.text-label <x-input.text-label
name="displayname" name="displayname"
type="text" type="text"
label="{{ __('Display name') }}" label="{{ __('Display name') }}"
placeholder="Phases of the moon..." placeholder="Phases of the moon..."
required="true" required="true"
description="If you leave this blank, we'll try to make a best guess for the name." /> 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 <x-input.text-label
name="color" name="color"
type="color" type="color"
value="#06D2A1" value="#06D2A1"
label="{{ __('Calendar color') }}" /> label="{{ __('Calendar color') }}"
/>
<div class="flex gap-4"> </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>

View 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>

View File

@ -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))

View File

@ -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)
{{-- 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) !!} {!! $renderOption($value, $label) !!}
@endif
@endforeach @endforeach
</optgroup> </optgroup>

View File

@ -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>

View File

@ -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) />

View 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>

View 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>

View File

@ -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>

View File

@ -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