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
$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 shouldnt
@ -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'] ?? '',

View File

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

View File

@ -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, 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([
'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 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([
'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 thats 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)

View File

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

View File

@ -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 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
{
// e.g. https://kithkin.lan/dav/calendars/1/48f888f3-c5c5-…/

View File

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

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

View File

@ -22,5 +22,7 @@ return [
'password' => 'Password',
'save_changes' => 'Save changes',
'settings' => 'Settings',
'timezone' => 'Time zone',
'timezone_select' => 'Select a time zone',
];

View File

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

View File

@ -39,4 +39,8 @@ details {
> .content {
@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 */
@keyframes event-slide {
from {

View File

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

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

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>
{{ __('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

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.
</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>

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([
'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))

View File

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

View File

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

View File

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

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

View File

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