feat(dav): implement personal absence settings

Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
pull/40767/head
Richard Steinmetz 8 months ago
parent 3e6642ab0b
commit 425e770c04
No known key found for this signature in database
GPG Key ID: 27137D9E7D273FB2

@ -4,6 +4,7 @@
*
* @author Georg Ehrke <oc.list@georgehrke.com>
* @author Roeland Jago Douma <roeland@famdouma.nl>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@ -28,7 +29,9 @@ return [
['name' => 'invitation_response#accept', 'url' => '/invitation/accept/{token}', 'verb' => 'GET'],
['name' => 'invitation_response#decline', 'url' => '/invitation/decline/{token}', 'verb' => 'GET'],
['name' => 'invitation_response#options', 'url' => '/invitation/moreOptions/{token}', 'verb' => 'GET'],
['name' => 'invitation_response#processMoreOptionsResult', 'url' => '/invitation/moreOptions/{token}', 'verb' => 'POST']
['name' => 'invitation_response#processMoreOptionsResult', 'url' => '/invitation/moreOptions/{token}', 'verb' => 'POST'],
['name' => 'availability_settings#updateAbsence', 'url' => '/settings/absence', 'verb' => 'POST'],
['name' => 'availability_settings#clearAbsence', 'url' => '/settings/absence', 'verb' => 'DELETE'],
],
'ocs' => [
['name' => 'direct#getUrl', 'url' => '/api/v1/direct', 'verb' => 'POST'],

@ -188,6 +188,7 @@ return array(
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => $baseDir . '/../lib/Connector/Sabre/SharesPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\TagList' => $baseDir . '/../lib/Connector/Sabre/TagList.php',
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php',
'OCA\\DAV\\Controller\\AvailabilitySettingsController' => $baseDir . '/../lib/Controller/AvailabilitySettingsController.php',
'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php',
'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php',
'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php',

@ -203,6 +203,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/SharesPlugin.php',
'OCA\\DAV\\Connector\\Sabre\\TagList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagList.php',
'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php',
'OCA\\DAV\\Controller\\AvailabilitySettingsController' => __DIR__ . '/..' . '/../lib/Controller/AvailabilitySettingsController.php',
'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php',
'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php',
'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php',

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
*
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\DAV\Controller;
use DateTimeImmutable;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\Service\AbsenceService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Response;
use OCP\IRequest;
class AvailabilitySettingsController extends Controller {
public function __construct(
IRequest $request,
private ?string $userId,
private AbsenceService $absenceService,
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* @throws \OCP\DB\Exception
* @throws \Exception
*/
#[NoAdminRequired]
public function updateAbsence(
string $firstDay,
string $lastDay,
string $status,
string $message,
): Response {
$userId = $this->userId;
if ($userId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$parsedFirstDay = new DateTimeImmutable($firstDay);
$parsedLastDay = new DateTimeImmutable($lastDay);
if ($parsedFirstDay->getTimestamp() >= $parsedLastDay->getTimestamp()) {
throw new \Exception('First day is on or after last day');
}
$absence = $this->absenceService->createOrUpdateAbsence(
$userId,
$firstDay,
$lastDay,
$status,
$message,
);
return new JSONResponse($absence);
}
/**
* @throws \OCP\DB\Exception
*/
#[NoAdminRequired]
public function clearAbsence(): Response {
$userId = $this->userId;
if ($userId === null) {
return new JSONResponse([], Http::STATUS_FORBIDDEN);
}
$this->absenceService->clearAbsence($userId);
return new JSONResponse([]);
}
}

@ -6,6 +6,7 @@ declare(strict_types=1);
* @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license GNU AGPL version 3 or any later version
*
@ -26,10 +27,13 @@ declare(strict_types=1);
namespace OCA\DAV\Settings;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\Db\AbsenceMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\Settings\ISettings;
use Psr\Log\LoggerInterface;
class AvailabilitySettings implements ISettings {
protected IConfig $config;
@ -38,7 +42,9 @@ class AvailabilitySettings implements ISettings {
public function __construct(IConfig $config,
IInitialState $initialState,
?string $userId) {
?string $userId,
private LoggerInterface $logger,
private AbsenceMapper $absenceMapper) {
$this->config = $config;
$this->initialState = $initialState;
$this->userId = $userId;
@ -54,6 +60,25 @@ class AvailabilitySettings implements ISettings {
'no'
)
);
$hideAbsenceSettings = $this->config->getAppValue(
Application::APP_ID,
'hide_absence_settings',
'yes',
) === 'yes';
$this->initialState->provideInitialState('hide_absence_settings', $hideAbsenceSettings);
if (!$hideAbsenceSettings) {
try {
$absence = $this->absenceMapper->findByUserId($this->userId);
$this->initialState->provideInitialState('absence', $absence);
} catch (DoesNotExistException) {
// The user has not yet set up an absence period.
// Logging this error is not necessary.
} catch (\OCP\DB\Exception $e) {
$this->logger->error("Could not find absence data for user $this->userId: " . $e->getMessage(), [
'exception' => $e,
]);
}
}
return new TemplateResponse(Application::APP_ID, 'settings-personal-availability');
}

@ -0,0 +1,160 @@
<!--
- @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
-
- @author Richard Steinmetz <richard@steinmetz.cloud>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="absence">
<div class="absence__dates">
<NcDateTimePickerNative id="absence-first-day"
v-model="firstDay"
:label="$t('dav', 'First day')"
class="absence__dates__picker" />
<NcDateTimePickerNative id="absence-last-day"
v-model="lastDay"
:label="$t('dav', 'Last day (inclusive)')"
class="absence__dates__picker" />
</div>
<NcTextField :value.sync="status" :label="$t('dav', 'Short absence status')" />
<NcTextArea :value.sync="message" :label="$t('dav', 'Long absence Message')" />
<div class="absence__buttons">
<NcButton :disabled="loading || !valid"
type="primary"
@click="saveForm">
{{ $t('dav', 'Save') }}
</NcButton>
<NcButton :disabled="loading || !valid"
type="error"
@click="clearAbsence">
{{ $t('dav', 'Disable absence') }}
</NcButton>
</div>
</div>
</template>
<script>
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import NcTextArea from '@nextcloud/vue/dist/Components/NcTextArea.js'
import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { formatDateAsYMD } from '../utils/date.js'
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
export default {
name: 'AbsenceForm',
components: {
NcButton,
NcTextField,
NcTextArea,
NcDateTimePickerNative,
},
data() {
const { firstDay, lastDay, status, message } = loadState('dav', 'absence', {})
return {
loading: false,
status: status ?? '',
message: message ?? '',
firstDay: firstDay ? new Date(firstDay) : new Date(),
lastDay: lastDay ? new Date(lastDay) : null,
}
},
computed: {
/**
* @return {boolean}
*/
valid() {
return !!this.firstDay
&& !!this.lastDay
&& !!this.status
&& this.lastDay > this.firstDay
},
},
methods: {
resetForm() {
this.status = ''
this.message = ''
this.firstDay = new Date()
this.lastDay = null
},
async saveForm() {
if (!this.valid) {
return
}
this.loading = true
try {
await axios.post(generateUrl('/apps/dav/settings/absence'), {
firstDay: formatDateAsYMD(this.firstDay),
lastDay: formatDateAsYMD(this.lastDay),
status: this.status,
message: this.message,
})
} catch (error) {
showError(this.$t('dav', 'Failed to save your absence settings'))
} finally {
this.loading = false
}
},
async clearAbsence() {
this.loading = true
try {
await axios.delete(generateUrl('/apps/dav/settings/absence'))
this.resetForm()
} catch (error) {
showError(this.$t('dav', 'Failed to clear your absence settings'))
} finally {
this.loading = false
}
},
},
}
</script>
<style lang="scss" scoped>
.absence {
display: flex;
flex-direction: column;
gap: 5px;
&__dates {
display: flex;
gap: 10px;
width: 100%;
&__picker {
flex: 1 auto;
::v-deep .native-datetime-picker--input {
margin-bottom: 0;
}
}
}
&__buttons {
display: flex;
gap: 5px;
}
}
</style>

@ -0,0 +1,205 @@
<template>
<div>
<div class="time-zone">
<label :for="`vs${timeZonePickerId}__combobox`" class="time-zone__heading">
{{ $t('dav', 'Time zone:') }}
</label>
<span class="time-zone-text">
<NcTimezonePicker v-model="timezone" :uid="timeZonePickerId" />
</span>
</div>
<CalendarAvailability :slots.sync="slots"
:loading="loading"
:l10n-to="$t('dav', 'to')"
:l10n-delete-slot="$t('dav', 'Delete slot')"
:l10n-empty-day="$t('dav', 'No working hours set')"
:l10n-add-slot="$t('dav', 'Add slot')"
:l10n-monday="$t('dav', 'Monday')"
:l10n-tuesday="$t('dav', 'Tuesday')"
:l10n-wednesday="$t('dav', 'Wednesday')"
:l10n-thursday="$t('dav', 'Thursday')"
:l10n-friday="$t('dav', 'Friday')"
:l10n-saturday="$t('dav', 'Saturday')"
:l10n-sunday="$t('dav', 'Sunday')" />
<NcCheckboxRadioSwitch :checked.sync="automated">
{{ $t('dav', 'Automatically set user status to "Do not disturb" outside of availability to mute all notifications.') }}
</NcCheckboxRadioSwitch>
<NcButton :disabled="loading || saving"
type="primary"
@click="save">
{{ $t('dav', 'Save') }}
</NcButton>
</div>
</template>
<script>
import { CalendarAvailability } from '@nextcloud/calendar-availability-vue'
import { loadState } from '@nextcloud/initial-state'
import {
showError,
showSuccess,
} from '@nextcloud/dialogs'
import {
findScheduleInboxAvailability,
getEmptySlots,
saveScheduleInboxAvailability,
} from '../service/CalendarService.js'
import {
enableUserStatusAutomation,
disableUserStatusAutomation,
} from '../service/PreferenceService.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcTimezonePicker from '@nextcloud/vue/dist/Components/NcTimezonePicker.js'
export default {
name: 'AvailabilityForm',
components: {
NcButton,
NcCheckboxRadioSwitch,
CalendarAvailability,
NcTimezonePicker,
},
data() {
// Try to determine the current timezone, and fall back to UTC otherwise
const defaultTimezoneId = (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone ?? 'UTC'
return {
loading: true,
saving: false,
timezone: defaultTimezoneId,
slots: getEmptySlots(),
automated: loadState('dav', 'user_status_automation') === 'yes',
}
},
computed: {
timeZonePickerId() {
return `tz-${(Math.random() + 1).toString(36).substring(7)}`
},
},
async mounted() {
try {
const slotData = await findScheduleInboxAvailability()
if (!slotData) {
console.info('no availability is set')
this.slots = getEmptySlots()
} else {
const { slots, timezoneId } = slotData
this.slots = slots
if (timezoneId) {
this.timezone = timezoneId
}
console.info('availability loaded', this.slots, this.timezoneId)
}
} catch (e) {
console.error('could not load existing availability', e)
showError(t('dav', 'Failed to load availability'))
} finally {
this.loading = false
}
},
methods: {
async save() {
try {
this.saving = true
await saveScheduleInboxAvailability(this.slots, this.timezone)
if (this.automated) {
await enableUserStatusAutomation()
} else {
await disableUserStatusAutomation()
}
showSuccess(t('dav', 'Saved availability'))
} catch (e) {
console.error('could not save availability', e)
showError(t('dav', 'Failed to save availability'))
} finally {
this.saving = false
}
},
},
}
</script>
<style lang="scss" scoped>
:deep(.availability-day) {
padding: 0 10px 0 10px;
position: absolute;
}
:deep(.availability-slots) {
display: flex;
white-space: normal;
}
:deep(.availability-slot) {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
:deep(.availability-slot-group) {
display: flex;
flex-direction: column;
}
:deep(.mx-input-wrapper) {
width: 85px;
}
:deep(.mx-datepicker) {
width: 97px;
}
:deep(.multiselect) {
border: 1px solid var(--color-border-dark);
width: 120px;
}
.time-zone {
padding: 32px 12px 12px 0;
display: flex;
flex-wrap: wrap;
&__heading {
margin-right: calc(var(--default-grid-baseline) * 2);
line-height: var(--default-clickable-area);
font-weight: bold;
}
}
.grid-table {
display: grid;
margin-bottom: 32px;
grid-column-gap: 24px;
grid-row-gap: 6px;
grid-template-columns: min-content auto min-content;
max-width: 500px;
}
.button {
align-self: flex-end;
}
:deep(.label-weekday) {
position: relative;
display: inline-flex;
padding-top: 4px;
align-self: center;
}
:deep(.delete-slot) {
padding-bottom: unset;
}
:deep(.add-another) {
align-self: center;
}
.to-text {
padding-right: 12px;
}
.empty-content {
color: var(--color-text-lighter);
margin-top: 4px;
align-self: center;
}
</style>

@ -0,0 +1,34 @@
/**
* @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
*
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* Format a date as 'YYYY-MM-DD'.
*
* @param {Date} date A date instance to format.
* @return {string} 'YYYY-MM-DD'
*/
export function formatDateAsYMD(date) {
const year = date.getUTCFullYear()
const month = (date.getUTCMonth() + 1).toString().padStart(2, '0')
const day = date.getUTCDate().toString().padStart(2, '0')
return `${year}-${month}-${day}`
}

@ -1,209 +1,34 @@
<template>
<NcSettingsSection :name="$t('dav', 'Availability')"
:description="$t('dav', 'If you configure your working hours, other users will see when you are out of office when they book a meeting.')">
<div class="time-zone">
<label :for="`vs${timeZonePickerId}__combobox`" class="time-zone__heading">
{{ $t('dav', 'Time zone:') }}
</label>
<span class="time-zone-text">
<NcTimezonePicker v-model="timezone" :uid="timeZonePickerId" />
</span>
</div>
<CalendarAvailability :slots.sync="slots"
:loading="loading"
:l10n-to="$t('dav', 'to')"
:l10n-delete-slot="$t('dav', 'Delete slot')"
:l10n-empty-day="$t('dav', 'No working hours set')"
:l10n-add-slot="$t('dav', 'Add slot')"
:l10n-monday="$t('dav', 'Monday')"
:l10n-tuesday="$t('dav', 'Tuesday')"
:l10n-wednesday="$t('dav', 'Wednesday')"
:l10n-thursday="$t('dav', 'Thursday')"
:l10n-friday="$t('dav', 'Friday')"
:l10n-saturday="$t('dav', 'Saturday')"
:l10n-sunday="$t('dav', 'Sunday')" />
<NcCheckboxRadioSwitch :checked.sync="automated">
{{ $t('dav', 'Automatically set user status to "Do not disturb" outside of availability to mute all notifications.') }}
</NcCheckboxRadioSwitch>
<NcButton :disabled="loading || saving"
type="primary"
@click="save">
{{ $t('dav', 'Save') }}
</NcButton>
</NcSettingsSection>
<div>
<NcSettingsSection :name="$t('dav', 'Availability')"
:description="$t('dav', 'If you configure your working hours, other users will see when you are out of office when they book a meeting.')">
<AvailabilityForm />
</NcSettingsSection>
<NcSettingsSection v-if="!hideAbsenceSettings"
:name="$t('dav', 'Absence')"
:description="$t('dav', 'Configure your next absence period.')">
<AbsenceForm />
</NcSettingsSection>
</div>
</template>
<script>
import { CalendarAvailability } from '@nextcloud/calendar-availability-vue'
import { loadState } from '@nextcloud/initial-state'
import {
showError,
showSuccess,
} from '@nextcloud/dialogs'
import {
findScheduleInboxAvailability,
getEmptySlots,
saveScheduleInboxAvailability,
} from '../service/CalendarService.js'
import {
enableUserStatusAutomation,
disableUserStatusAutomation,
} from '../service/PreferenceService.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
import NcTimezonePicker from '@nextcloud/vue/dist/Components/NcTimezonePicker.js'
import AbsenceForm from '../components/AbsenceForm.vue'
import AvailabilityForm from '../components/AvailabilityForm.vue'
import { loadState } from '@nextcloud/initial-state'
export default {
name: 'Availability',
components: {
NcButton,
NcCheckboxRadioSwitch,
CalendarAvailability,
NcSettingsSection,
NcTimezonePicker,
AbsenceForm,
AvailabilityForm,
},
data() {
// Try to determine the current timezone, and fall back to UTC otherwise
const defaultTimezoneId = (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone ?? 'UTC'
return {
loading: true,
saving: false,
timezone: defaultTimezoneId,
slots: getEmptySlots(),
automated: loadState('dav', 'user_status_automation') === 'yes',
}
},
computed: {
timeZonePickerId() {
return `tz-${(Math.random() + 1).toString(36).substring(7)}`
},
},
async mounted() {
try {
const slotData = await findScheduleInboxAvailability()
if (!slotData) {
console.info('no availability is set')
this.slots = getEmptySlots()
} else {
const { slots, timezoneId } = slotData
this.slots = slots
if (timezoneId) {
this.timezone = timezoneId
}
console.info('availability loaded', this.slots, this.timezoneId)
}
} catch (e) {
console.error('could not load existing availability', e)
showError(t('dav', 'Failed to load availability'))
} finally {
this.loading = false
hideAbsenceSettings: loadState('dav', 'hide_absence_settings', true),
}
},
methods: {
async save() {
try {
this.saving = true
await saveScheduleInboxAvailability(this.slots, this.timezone)
if (this.automated) {
await enableUserStatusAutomation()
} else {
await disableUserStatusAutomation()
}
showSuccess(t('dav', 'Saved availability'))
} catch (e) {
console.error('could not save availability', e)
showError(t('dav', 'Failed to save availability'))
} finally {
this.saving = false
}
},
},
}
</script>
<style lang="scss" scoped>
:deep(.availability-day) {
padding: 0 10px 0 10px;
position: absolute;
}
:deep(.availability-slots) {
display: flex;
white-space: normal;
}
:deep(.availability-slot) {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
:deep(.availability-slot-group) {
display: flex;
flex-direction: column;
}
:deep(.mx-input-wrapper) {
width: 85px;
}
:deep(.mx-datepicker) {
width: 97px;
}
:deep(.multiselect) {
border: 1px solid var(--color-border-dark);
width: 120px;
}
.time-zone {
padding: 32px 12px 12px 0;
display: flex;
flex-wrap: wrap;
&__heading {
margin-right: calc(var(--default-grid-baseline) * 2);
line-height: var(--default-clickable-area);
font-weight: bold;
}
}
.grid-table {
display: grid;
margin-bottom: 32px;
grid-column-gap: 24px;
grid-row-gap: 6px;
grid-template-columns: min-content auto min-content;
max-width: 500px;
}
.button {
align-self: flex-end;
}
:deep(.label-weekday) {
position: relative;
display: inline-flex;
padding-top: 4px;
align-self: center;
}
:deep(.delete-slot) {
padding-bottom: unset;
}
:deep(.add-another) {
align-self: center;
}
.to-text {
padding-right: 12px;
}
.empty-content {
color: var(--color-text-lighter);
margin-top: 4px;
align-self: center;
}
</style>

File diff suppressed because one or more lines are too long

@ -62,3 +62,25 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
*
* @author Richard Steinmetz <richard@steinmetz.cloud>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save