Add calendar integrations and collaboration updates
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-05-05 15:25:53 -04:00
parent c49f03ec06
commit b66c10b681
82 changed files with 8420 additions and 2048 deletions

View File

@@ -1,20 +1,44 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { mdiCalendarPlus, mdiChevronDown, mdiChevronLeft, mdiChevronRight, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { organizationPermissions, useOrganizationStore } from '@/features/organizations/stores/organizationStore.js';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useCalendarIntegrationsStore } from '@/features/content/stores/calendarIntegrationsStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
const organizationStore = useOrganizationStore();
const campaignsStore = useCampaignsStore();
const contentItemsStore = useContentItemsStore();
const calendarStore = useCalendarIntegrationsStore();
const today = startOfDay(new Date());
const viewMode = ref(parseViewMode(route.query.view));
const cursorDate = ref(parseCursorDate(route.query.date, today));
const isAddCalendarOpen = ref(route.query.addCalendar === 'true');
const isCalendarSelectorOpen = ref(false);
const activeAddMode = ref('catalog');
const catalogFilters = reactive({
search: '',
country: '',
category: '',
});
const customCalendarForm = reactive({
title: '',
sourceUrl: '',
color: '#2F80ED',
category: 'public-holiday',
scope: 'User',
});
const addCalendarError = ref('');
const contentStatusMeta = {
Draft: { tone: 'production' },
@@ -46,7 +70,10 @@
.filter(item => item.dueDate)
.map(item => buildContentEntry(item));
return [...campaignEntries, ...contentEntries].sort(sortByDate);
const importedEntries = calendarStore.visibleEvents
.map(event => buildImportedCalendarEntry(event));
return [...campaignEntries, ...contentEntries, ...importedEntries].sort(sortByDate);
});
const entriesByDay = computed(() => {
@@ -133,6 +160,12 @@
})
);
const upcomingEntries = computed(() =>
calendarEntries.value
.filter(entry => entry.scheduledAt >= today)
.slice(0, 80)
);
const isLoading = computed(() =>
contentItemsStore.isLoading || campaignsStore.isLoading
);
@@ -142,6 +175,44 @@
);
const isCalendarView = computed(() => viewMode.value === 'month' || viewMode.value === 'week');
const calendarWorkspaceId = computed(() =>
workspaceStore.activeWorkspaceId ?? workspaceStore.visibleWorkspaceIds[0] ?? null
);
const calendarRange = computed(() => {
if (viewMode.value === 'week') {
const start = startOfWeek(cursorDate.value);
return {
startDate: dateKey(start),
endDate: dateKey(addDays(start, 6)),
};
}
const start = startOfWeek(startOfMonth(cursorDate.value));
const end = endOfWeek(endOfMonth(cursorDate.value));
return {
startDate: dateKey(start),
endDate: dateKey(end),
};
});
const canManageOrganizationCalendars = computed(() =>
organizationStore.userCan(organizationStore.activeOrganization, organizationPermissions.manageConnectors)
);
const canManageWorkspaceCalendars = computed(() => authStore.isManager);
const availableCalendarSources = computed(() =>
[...calendarStore.sources].sort((left, right) => {
const scopeSort = ['Organization', 'Workspace', 'User'];
const scopeDiff = scopeSort.indexOf(left.scope) - scopeSort.indexOf(right.scope);
return scopeDiff || left.displayTitle.localeCompare(right.displayTitle);
})
);
const visibleCalendarSourceCount = computed(() =>
availableCalendarSources.value.filter(source => sourceIsVisible(source.id)).length
);
const addScopeOptions = computed(() => [
...(canManageOrganizationCalendars.value ? [{ value: 'Organization', label: t('contentItems.calendar.organization') }] : []),
...(canManageWorkspaceCalendars.value && calendarWorkspaceId.value ? [{ value: 'Workspace', label: t('contentItems.calendar.workspace') }] : []),
{ value: 'User', label: t('contentItems.calendar.mine') },
]);
function buildDay(date, isOutsideMonth) {
const key = dateKey(date);
@@ -191,6 +262,205 @@
};
}
function buildImportedCalendarEntry(event) {
const source = calendarStore.sourceById(event.calendarSourceId);
const scheduledAt = parseCalendarEventDate(event);
return {
id: event.id,
type: 'imported-calendar',
title: event.title,
subtitle: source?.displayTitle ?? t('contentItems.calendar.importedEvent'),
scheduledAt,
dayKey: dateKey(event.startDate),
timeLabel: event.isAllDay ? t('contentItems.calendar.allDay') : formatHour(scheduledAt),
tone: 'calendar-context',
source,
color: source?.color ?? '#64748b',
route: null,
};
}
function parseCalendarEventDate(event) {
if (event.startLocalDateTime) {
return new Date(event.startLocalDateTime);
}
if (event.startUtc) {
return new Date(event.startUtc);
}
return new Date(`${event.startDate}T00:00:00`);
}
function normalizeCalendarUrl(value) {
return String(value ?? '').trim().replace(/\/$/, '').toLowerCase();
}
function entryStyle(entry) {
if (entry.type !== 'imported-calendar') {
return {};
}
const color = entry.color || '#64748b';
return {
borderColor: `${color}55`,
borderLeftColor: color,
};
}
function sourceIsVisible(sourceId) {
return !calendarStore.hiddenSourceIds.has(sourceId);
}
function toggleSource(sourceId) {
calendarStore.toggleSourceVisibility(sourceId);
}
function openAddCalendar() {
isCalendarSelectorOpen.value = false;
isAddCalendarOpen.value = true;
}
function createFromImportedEvent(entry) {
router.push({
name: 'content-item-create',
query: {
date: entry.dayKey,
title: entry.title,
},
});
}
async function refreshCalendarData() {
if (!calendarWorkspaceId.value) {
return;
}
await calendarStore.fetchSources(calendarWorkspaceId.value);
const sourcesToRefresh = calendarStore.sources.filter(source =>
source.isEnabled &&
!calendarStore.hiddenSourceIds.has(source.id) &&
(!source.lastSuccessfulSyncAt || source.lastSyncError)
);
await Promise.all(sourcesToRefresh.map(source => calendarStore.refreshSource(source.id)));
await calendarStore.fetchEvents({
workspaceId: calendarWorkspaceId.value,
startDate: calendarRange.value.startDate,
endDate: calendarRange.value.endDate,
});
}
async function searchCatalog() {
await calendarStore.searchCatalog({
search: catalogFilters.search || undefined,
country: catalogFilters.country || undefined,
category: catalogFilters.category || undefined,
});
}
async function addCatalogSource(entry) {
if (catalogEntryAlreadyAdded(entry)) {
addCalendarError.value = t('contentItems.calendar.errors.duplicate');
return;
}
await addCalendarSource({
title: entry.title,
sourceUrl: entry.sourceUrl,
color: entry.defaultColor,
category: entry.category,
catalogSourceReference: String(entry.id),
});
}
async function addCustomSource() {
await addCalendarSource({
title: customCalendarForm.title,
sourceUrl: customCalendarForm.sourceUrl,
color: customCalendarForm.color,
category: customCalendarForm.category,
catalogSourceReference: null,
});
}
async function addCalendarSource({ title, sourceUrl, color, category, catalogSourceReference }) {
addCalendarError.value = '';
const scope = customCalendarForm.scope;
const payload = {
scope,
organizationId: scope === 'Organization' ? organizationStore.activeOrganization?.id : null,
workspaceId: scope === 'Workspace' ? calendarWorkspaceId.value : null,
sourceUrl,
catalogSourceReference,
displayTitle: title.trim(),
color,
category,
isEnabled: true,
inheritanceMode: scope === 'Organization' ? 'Optional' : null,
};
if (!payload.displayTitle || !payload.sourceUrl) {
addCalendarError.value = t('contentItems.calendar.errors.required');
return;
}
if (sourceAlreadyAdded(payload)) {
addCalendarError.value = t('contentItems.calendar.errors.duplicate');
return;
}
try {
const source = await calendarStore.createSource(payload);
await calendarStore.refreshSource(source?.id);
isAddCalendarOpen.value = false;
customCalendarForm.title = '';
customCalendarForm.sourceUrl = '';
await refreshCalendarData();
} catch {
addCalendarError.value = t('contentItems.calendar.errors.createFailed');
}
}
function catalogEntryAlreadyAdded(entry) {
return sourceAlreadyAdded({
scope: customCalendarForm.scope,
organizationId: customCalendarForm.scope === 'Organization' ? organizationStore.activeOrganization?.id : null,
workspaceId: customCalendarForm.scope === 'Workspace' ? calendarWorkspaceId.value : null,
sourceUrl: entry.sourceUrl,
catalogSourceReference: String(entry.id),
});
}
function sourceAlreadyAdded(payload) {
const normalizedUrl = normalizeCalendarUrl(payload.sourceUrl);
const normalizedReference = String(payload.catalogSourceReference ?? '').trim();
return calendarStore.sources.some(source => {
if (source.scope !== payload.scope) {
return false;
}
if (payload.scope === 'Organization' && source.organizationId !== payload.organizationId) {
return false;
}
if (payload.scope === 'Workspace' && source.workspaceId !== payload.workspaceId) {
return false;
}
if (payload.scope === 'User' && (source.organizationId || source.workspaceId)) {
return false;
}
return Boolean(normalizedReference && source.catalogSourceReference === normalizedReference) ||
Boolean(normalizedUrl && normalizeCalendarUrl(source.sourceUrl) === normalizedUrl);
});
}
function setView(mode) {
viewMode.value = mode;
@@ -228,6 +498,14 @@
return value ? new Date(value).toLocaleDateString() : t('contentItems.noDueDate');
}
function formatEntryDate(value) {
return new Intl.DateTimeFormat(locale.value, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(value);
}
function startOfDay(value) {
const date = new Date(value);
date.setHours(0, 0, 0, 0);
@@ -271,6 +549,10 @@
}
function dateKey(value) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
return value.slice(0, 10);
}
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
@@ -317,6 +599,33 @@
});
}
);
watch(
() => [calendarWorkspaceId.value, viewMode.value, calendarRange.value.startDate, calendarRange.value.endDate],
async () => {
await refreshCalendarData();
},
{ immediate: true }
);
watch(
() => addScopeOptions.value.map(option => option.value).join(','),
() => {
if (!addScopeOptions.value.some(option => option.value === customCalendarForm.scope)) {
customCalendarForm.scope = addScopeOptions.value[0]?.value ?? 'User';
}
},
{ immediate: true }
);
watch(
() => isAddCalendarOpen.value,
async value => {
if (value && calendarStore.catalogEntries.length === 0) {
await searchCatalog();
}
}
);
</script>
<template>
@@ -326,31 +635,84 @@
<h1>{{ t('contentItems.title') }}</h1>
</div>
<div class="view-toggle">
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'month' }"
type="button"
@click="setView('month')"
>
{{ t('dashboard.month') }}
</button>
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'week' }"
type="button"
@click="setView('week')"
>
{{ t('dashboard.week') }}
</button>
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'upcoming' }"
type="button"
@click="setView('upcoming')"
>
{{ t('contentItems.upcoming') }}
</button>
<div class="header-actions">
<div class="calendar-selector">
<button
class="calendar-selector-button"
type="button"
@click="isCalendarSelectorOpen = !isCalendarSelectorOpen"
>
<span>{{ t('contentItems.calendar.calendars') }}</span>
<strong>{{ visibleCalendarSourceCount }}/{{ availableCalendarSources.length }}</strong>
<v-icon :icon="mdiChevronDown" />
</button>
<div
v-if="isCalendarSelectorOpen"
class="calendar-selector-menu"
>
<button
v-for="source in availableCalendarSources"
:key="source.id"
class="calendar-selector-row"
type="button"
@click="toggleSource(source.id)"
>
<span
class="source-swatch"
:style="{ background: source.color }"
/>
<span class="calendar-selector-title">{{ source.displayTitle }}</span>
<span
class="visibility-switch"
:class="{ active: sourceIsVisible(source.id) }"
/>
</button>
<div
v-if="!availableCalendarSources.length"
class="calendar-selector-empty"
>
{{ t('contentItems.calendar.noCalendars') }}
</div>
<button
class="calendar-selector-add"
type="button"
@click="openAddCalendar"
>
<v-icon :icon="mdiCalendarPlus" />
<span>{{ t('contentItems.calendar.addCalendar') }}</span>
</button>
</div>
</div>
<div class="view-toggle">
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'month' }"
type="button"
@click="setView('month')"
>
{{ t('dashboard.month') }}
</button>
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'week' }"
type="button"
@click="setView('week')"
>
{{ t('dashboard.week') }}
</button>
<button
class="toggle-button"
:class="{ 'toggle-button-active': viewMode === 'upcoming' }"
type="button"
@click="setView('upcoming')"
>
{{ t('contentItems.upcoming') }}
</button>
</div>
</div>
</div>
@@ -434,17 +796,34 @@
v-if="day.entries.length"
class="day-entries"
>
<router-link
<template
v-for="entry in viewMode === 'month' ? day.entries.slice(0, 3) : day.entries"
:key="`${entry.type}-${entry.id}`"
:to="entry.route"
class="calendar-entry"
:class="entry.tone"
>
<span class="entry-time">{{ entry.timeLabel }}</span>
<strong>{{ entry.title }}</strong>
<span>{{ entry.subtitle }}</span>
</router-link>
<button
v-if="entry.type === 'imported-calendar'"
class="calendar-entry calendar-context-entry"
:class="entry.tone"
:style="entryStyle(entry)"
type="button"
@click="createFromImportedEvent(entry)"
>
<span class="entry-time">{{ entry.timeLabel }}</span>
<strong>{{ entry.title }}</strong>
<span>{{ entry.subtitle }}</span>
</button>
<router-link
v-else
:to="entry.route"
class="calendar-entry"
:class="entry.tone"
>
<span class="entry-time">{{ entry.timeLabel }}</span>
<strong>{{ entry.title }}</strong>
<span>{{ entry.subtitle }}</span>
</router-link>
</template>
<div
v-if="viewMode === 'month' && day.entries.length > 3"
@@ -465,23 +844,57 @@
</article>
<div
v-else-if="upcomingItems.length"
v-else-if="upcomingEntries.length"
class="item-grid"
>
<router-link
v-for="item in upcomingItems"
:key="item.id"
:to="{ name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } }"
class="item-card"
<template
v-for="entry in upcomingEntries"
:key="`${entry.type}-${entry.id}`"
>
<div class="version-chip">{{ item.currentRevisionLabel }}</div>
<strong>{{ item.title }}</strong>
<span>{{ item.publicationTargets }}</span>
<div class="status-row">
<em>{{ item.status }}</em>
<small>{{ formatDueDate(item.dueDate) }}</small>
</div>
</router-link>
<button
v-if="entry.type === 'imported-calendar'"
class="item-card calendar-upcoming-card"
:style="entryStyle(entry)"
type="button"
@click="createFromImportedEvent(entry)"
>
<div class="version-chip">{{ t('contentItems.calendar.context') }}</div>
<strong>{{ entry.title }}</strong>
<span>{{ entry.subtitle }}</span>
<div class="status-row">
<em>{{ entry.timeLabel }}</em>
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
</div>
</button>
<router-link
v-else-if="entry.type === 'content'"
:to="entry.route"
class="item-card"
>
<div class="version-chip">{{ contentItemsStore.items.find(item => item.id === entry.id)?.currentRevisionLabel }}</div>
<strong>{{ entry.title }}</strong>
<span>{{ contentItemsStore.items.find(item => item.id === entry.id)?.publicationTargets }}</span>
<div class="status-row">
<em>{{ contentItemsStore.items.find(item => item.id === entry.id)?.status }}</em>
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
</div>
</router-link>
<router-link
v-else
:to="entry.route"
class="item-card"
>
<div class="version-chip">{{ t('dashboard.campaignDeadline') }}</div>
<strong>{{ entry.title }}</strong>
<span>{{ entry.subtitle }}</span>
<div class="status-row">
<em>{{ entry.timeLabel }}</em>
<small>{{ formatEntryDate(entry.scheduledAt) }}</small>
</div>
</router-link>
</template>
</div>
<div
@@ -490,6 +903,155 @@
>
{{ t('contentItems.empty') }}
</div>
<v-dialog
v-model="isAddCalendarOpen"
max-width="760"
>
<div class="calendar-dialog">
<div class="dialog-header">
<strong>{{ t('contentItems.calendar.addCalendar') }}</strong>
<button
class="icon-button"
type="button"
@click="isAddCalendarOpen = false"
>
<v-icon :icon="mdiClose" />
</button>
</div>
<div class="add-mode-toggle">
<button
class="toggle-button"
:class="{ 'toggle-button-active': activeAddMode === 'catalog' }"
type="button"
@click="activeAddMode = 'catalog'"
>
{{ t('contentItems.calendar.catalog') }}
</button>
<button
class="toggle-button"
:class="{ 'toggle-button-active': activeAddMode === 'custom' }"
type="button"
@click="activeAddMode = 'custom'"
>
{{ t('contentItems.calendar.customIcs') }}
</button>
</div>
<div class="scope-row">
<label
v-for="option in addScopeOptions"
:key="option.value"
class="scope-option"
>
<input
v-model="customCalendarForm.scope"
type="radio"
:value="option.value"
>
<span>{{ option.label }}</span>
</label>
</div>
<div
v-if="activeAddMode === 'catalog'"
class="catalog-panel"
>
<div class="catalog-search">
<input
v-model="catalogFilters.search"
type="search"
:placeholder="t('contentItems.calendar.searchCatalog')"
>
<input
v-model="catalogFilters.country"
type="text"
maxlength="2"
:placeholder="t('contentItems.calendar.country')"
>
<input
v-model="catalogFilters.category"
type="text"
:placeholder="t('contentItems.calendar.category')"
>
<button
class="text-button"
type="button"
@click="searchCatalog"
>
<v-icon :icon="mdiMagnify" />
<span>{{ t('contentItems.calendar.search') }}</span>
</button>
</div>
<div class="catalog-results">
<button
v-for="entry in calendarStore.catalogEntries"
:key="entry.id"
class="catalog-entry"
:class="{ 'catalog-entry-disabled': catalogEntryAlreadyAdded(entry) }"
type="button"
:disabled="catalogEntryAlreadyAdded(entry)"
@click="addCatalogSource(entry)"
>
<span
class="source-swatch"
:style="{ background: entry.defaultColor }"
/>
<strong>{{ entry.title }}</strong>
<span>
{{ catalogEntryAlreadyAdded(entry)
? t('contentItems.calendar.alreadyAdded')
: `${entry.providerName} · ${entry.category}` }}
</span>
</button>
</div>
</div>
<form
v-else
class="custom-calendar-form"
@submit.prevent="addCustomSource"
>
<input
v-model="customCalendarForm.title"
type="text"
:placeholder="t('contentItems.calendar.calendarName')"
>
<input
v-model="customCalendarForm.sourceUrl"
type="url"
:placeholder="t('contentItems.calendar.icsUrl')"
>
<div class="custom-form-row">
<input
v-model="customCalendarForm.color"
type="color"
>
<input
v-model="customCalendarForm.category"
type="text"
:placeholder="t('contentItems.calendar.category')"
>
<button
class="text-button"
type="submit"
>
<v-icon :icon="mdiPlus" />
<span>{{ t('contentItems.calendar.addCalendar') }}</span>
</button>
</div>
</form>
<p
v-if="addCalendarError || calendarStore.error"
class="dialog-error"
>
{{ addCalendarError || calendarStore.error }}
</p>
</div>
</v-dialog>
</section>
</template>
@@ -514,12 +1076,84 @@
color: #526178;
}
.header-actions {
@apply flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end;
}
.view-toggle {
@apply inline-flex w-fit rounded-full border p-1;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.1);
}
.calendar-selector {
@apply relative w-full sm:w-auto;
}
.calendar-selector-button {
@apply inline-flex min-h-11 w-full items-center justify-between gap-2 rounded-full border px-4 py-2 text-sm font-bold transition sm:w-auto;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
}
.calendar-selector-button strong {
@apply rounded-full px-2 py-0.5 text-xs;
background: rgba(15, 118, 110, 0.1);
color: #0f766e;
}
.calendar-selector-menu {
@apply absolute right-0 top-[calc(100%+0.5rem)] z-30 flex w-full min-w-72 flex-col gap-1 rounded-[1rem] border p-2 shadow-xl sm:w-80;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.1);
}
.calendar-selector-row,
.calendar-selector-add {
@apply flex min-h-11 w-full items-center gap-3 rounded-[0.75rem] px-3 text-left text-sm font-semibold transition;
color: #172033;
}
.calendar-selector-row:hover,
.calendar-selector-add:hover {
background: #f8fafc;
}
.calendar-selector-title {
@apply min-w-0 flex-1 truncate;
}
.calendar-selector-empty {
@apply px-3 py-2 text-sm;
color: #526178;
}
.calendar-selector-add {
@apply border-t;
border-color: rgba(23, 32, 51, 0.08);
color: #0f766e;
}
.visibility-switch {
@apply relative h-6 w-10 shrink-0 rounded-full transition;
background: rgba(148, 163, 184, 0.35);
}
.visibility-switch::after {
@apply absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition;
content: '';
box-shadow: 0 1px 4px rgba(23, 32, 51, 0.2);
}
.visibility-switch.active {
background: #0f766e;
}
.visibility-switch.active::after {
transform: translateX(1rem);
}
.toggle-button,
.icon-button,
.text-button {
@@ -584,6 +1218,10 @@
color: #172033;
}
.source-swatch {
@apply h-3 w-3 shrink-0 rounded-full;
}
.calendar-grid {
@apply grid gap-3;
grid-template-columns: repeat(7, minmax(0, 1fr));
@@ -631,6 +1269,11 @@
@apply flex flex-col gap-0.5 rounded-[1rem] border px-3 py-2 no-underline transition;
}
button.calendar-entry,
button.item-card {
@apply w-full text-left;
}
.calendar-entry:hover,
.item-card:hover {
transform: translateY(-1px);
@@ -690,6 +1333,105 @@
border-color: rgba(148, 163, 184, 0.18);
}
.calendar-context-entry {
border-left-width: 4px;
background: #ffffff;
opacity: 0.86;
}
.calendar-context-entry strong {
color: #334155;
}
.calendar-upcoming-card {
border-left-width: 4px;
}
.calendar-dialog {
@apply flex flex-col gap-4 rounded-[1.5rem] border bg-white p-5;
border-color: rgba(23, 32, 51, 0.1);
}
.dialog-header,
.add-mode-toggle,
.scope-row,
.catalog-search,
.custom-form-row {
@apply flex flex-wrap items-center gap-3;
}
.dialog-header {
@apply justify-between;
}
.dialog-header strong {
@apply text-lg font-black;
color: #172033;
}
.scope-option {
@apply inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-semibold;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
}
.catalog-panel,
.custom-calendar-form,
.catalog-results {
@apply flex flex-col gap-3;
}
.catalog-search input,
.custom-calendar-form input {
@apply min-h-11 rounded-[0.75rem] border px-3 text-sm;
border-color: rgba(23, 32, 51, 0.12);
color: #172033;
}
.catalog-search input[type='search'],
.custom-calendar-form input[type='url'],
.custom-calendar-form input[type='text'] {
@apply min-w-0 flex-1;
}
.catalog-results {
@apply max-h-[22rem] overflow-auto;
}
.catalog-entry {
@apply grid min-h-14 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 rounded-[0.75rem] border px-3 py-2 text-left transition;
border-color: rgba(23, 32, 51, 0.08);
background: #ffffff;
}
.catalog-entry:hover {
background: #f8fafc;
}
.catalog-entry-disabled {
cursor: not-allowed;
opacity: 0.58;
}
.catalog-entry-disabled:hover {
background: #ffffff;
}
.catalog-entry strong {
@apply text-sm font-bold;
color: #172033;
}
.catalog-entry span:last-child {
@apply col-start-2 text-xs;
color: #526178;
}
.dialog-error {
@apply text-sm font-semibold;
color: #b91c1c;
}
.item-grid {
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
}