1494 lines
47 KiB
Vue
1494 lines
47 KiB
Vue
<script setup>
|
|
import { computed, reactive, ref, watch } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
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' },
|
|
'In production': { tone: 'production' },
|
|
'In approval': { tone: 'approval' },
|
|
Approved: { tone: 'ready' },
|
|
Scheduled: { tone: 'ready' },
|
|
Published: { tone: 'published' },
|
|
};
|
|
|
|
const contentItemsByCampaignId = computed(() => {
|
|
const grouped = new Map();
|
|
|
|
for (const item of contentItemsStore.items) {
|
|
const existing = grouped.get(item.campaignId) ?? [];
|
|
existing.push(item);
|
|
grouped.set(item.campaignId, existing);
|
|
}
|
|
|
|
return grouped;
|
|
});
|
|
|
|
const calendarEntries = computed(() => {
|
|
const campaignEntries = campaignsStore.campaigns
|
|
.filter(campaign => campaign.endDate || campaign.startDate)
|
|
.map(campaign => buildCampaignEntry(campaign));
|
|
|
|
const contentEntries = contentItemsStore.items
|
|
.filter(item => item.dueDate)
|
|
.map(item => buildContentEntry(item));
|
|
|
|
const importedEntries = calendarStore.visibleEvents
|
|
.map(event => buildImportedCalendarEntry(event));
|
|
|
|
return [...campaignEntries, ...contentEntries, ...importedEntries].sort(sortByDate);
|
|
});
|
|
|
|
const entriesByDay = computed(() => {
|
|
const grouped = new Map();
|
|
|
|
for (const entry of calendarEntries.value) {
|
|
const existing = grouped.get(entry.dayKey) ?? [];
|
|
existing.push(entry);
|
|
grouped.set(entry.dayKey, existing);
|
|
}
|
|
|
|
return grouped;
|
|
});
|
|
|
|
const visibleDays = computed(() => {
|
|
if (viewMode.value === 'week') {
|
|
const start = startOfWeek(cursorDate.value);
|
|
return Array.from({ length: 7 }, (_, index) => buildDay(addDays(start, index), false));
|
|
}
|
|
|
|
const start = startOfWeek(startOfMonth(cursorDate.value));
|
|
const end = endOfWeek(endOfMonth(cursorDate.value));
|
|
const days = [];
|
|
let current = start;
|
|
|
|
while (current <= end) {
|
|
days.push(buildDay(current, current.getMonth() !== cursorDate.value.getMonth()));
|
|
current = addDays(current, 1);
|
|
}
|
|
|
|
return days;
|
|
});
|
|
|
|
const weekdayLabels = computed(() => {
|
|
const base = startOfWeek(cursorDate.value);
|
|
|
|
return Array.from({ length: 7 }, (_, index) =>
|
|
new Intl.DateTimeFormat(locale.value, { weekday: 'short' }).format(addDays(base, index))
|
|
);
|
|
});
|
|
|
|
const periodLabel = computed(() => {
|
|
if (viewMode.value === 'week') {
|
|
const start = startOfWeek(cursorDate.value);
|
|
const end = addDays(start, 6);
|
|
const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear();
|
|
|
|
if (sameMonth) {
|
|
return new Intl.DateTimeFormat(locale.value, {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
}).formatRange(start, end);
|
|
}
|
|
|
|
return new Intl.DateTimeFormat(locale.value, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
}).formatRange(start, end);
|
|
}
|
|
|
|
return new Intl.DateTimeFormat(locale.value, {
|
|
month: 'long',
|
|
year: 'numeric',
|
|
}).format(cursorDate.value);
|
|
});
|
|
|
|
const upcomingItems = computed(() =>
|
|
[...contentItemsStore.items].sort((left, right) => {
|
|
if (!left.dueDate && !right.dueDate) {
|
|
return left.title.localeCompare(right.title);
|
|
}
|
|
|
|
if (!left.dueDate) {
|
|
return 1;
|
|
}
|
|
|
|
if (!right.dueDate) {
|
|
return -1;
|
|
}
|
|
|
|
return new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime();
|
|
})
|
|
);
|
|
|
|
const upcomingEntries = computed(() =>
|
|
calendarEntries.value
|
|
.filter(entry => entry.scheduledAt >= today)
|
|
.slice(0, 80)
|
|
);
|
|
|
|
const isLoading = computed(() =>
|
|
contentItemsStore.isLoading || campaignsStore.isLoading
|
|
);
|
|
|
|
const pageError = computed(() =>
|
|
contentItemsStore.error || campaignsStore.error
|
|
);
|
|
|
|
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);
|
|
|
|
return {
|
|
key,
|
|
date,
|
|
entries: entriesByDay.value.get(key) ?? [],
|
|
isOutsideMonth,
|
|
isToday: key === dateKey(today),
|
|
};
|
|
}
|
|
|
|
function buildContentEntry(item) {
|
|
const statusMeta = contentStatusMeta[item.status] ?? { tone: 'production' };
|
|
const campaign = campaignsStore.campaigns.find(candidate => candidate.id === item.campaignId);
|
|
|
|
return {
|
|
id: item.id,
|
|
type: 'content',
|
|
title: item.title,
|
|
subtitle: campaign?.name ?? t('dashboard.labels.unassignedCampaign'),
|
|
scheduledAt: new Date(item.dueDate),
|
|
dayKey: dateKey(item.dueDate),
|
|
timeLabel: formatHour(item.dueDate),
|
|
tone: statusMeta.tone,
|
|
route: { name: 'content-item-detail', params: { id: item.id }, query: { returnTo: route.fullPath } },
|
|
};
|
|
}
|
|
|
|
function buildCampaignEntry(campaign) {
|
|
const campaignItems = contentItemsByCampaignId.value.get(campaign.id) ?? [];
|
|
const approvedCount = campaignItems.filter(item => ['Approved', 'Scheduled', 'Published'].includes(item.status)).length;
|
|
|
|
return {
|
|
id: campaign.id,
|
|
type: 'campaign',
|
|
title: campaign.name,
|
|
subtitle: campaignItems.length
|
|
? t('dashboard.campaignProgress', { scheduled: campaignItems.length, approved: approvedCount })
|
|
: t('dashboard.readiness.missing'),
|
|
scheduledAt: new Date(campaign.endDate ?? campaign.startDate),
|
|
dayKey: dateKey(campaign.endDate ?? campaign.startDate),
|
|
timeLabel: t('dashboard.campaignDeadline'),
|
|
tone: campaignItems.length ? 'campaign' : 'risk',
|
|
route: { name: 'campaign-detail', params: { campaignId: campaign.id } },
|
|
};
|
|
}
|
|
|
|
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;
|
|
|
|
if (mode === 'month') {
|
|
cursorDate.value = startOfMonth(cursorDate.value);
|
|
}
|
|
|
|
if (mode === 'week') {
|
|
cursorDate.value = startOfWeek(cursorDate.value);
|
|
}
|
|
}
|
|
|
|
function shiftPeriod(direction) {
|
|
cursorDate.value = viewMode.value === 'month'
|
|
? addMonths(cursorDate.value, direction)
|
|
: addDays(cursorDate.value, direction * 7);
|
|
}
|
|
|
|
function jumpToToday() {
|
|
cursorDate.value = viewMode.value === 'month' ? startOfMonth(today) : startOfWeek(today);
|
|
}
|
|
|
|
function formatDayNumber(date) {
|
|
return new Intl.DateTimeFormat(locale.value, { day: 'numeric' }).format(date);
|
|
}
|
|
|
|
function formatHour(value) {
|
|
return new Intl.DateTimeFormat(locale.value, {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function formatDueDate(value) {
|
|
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);
|
|
return date;
|
|
}
|
|
|
|
function startOfWeek(value) {
|
|
const date = startOfDay(value);
|
|
const day = date.getDay();
|
|
const diff = day === 0 ? -6 : 1 - day;
|
|
return addDays(date, diff);
|
|
}
|
|
|
|
function endOfWeek(value) {
|
|
return addDays(startOfWeek(value), 6);
|
|
}
|
|
|
|
function startOfMonth(value) {
|
|
const date = startOfDay(value);
|
|
date.setDate(1);
|
|
return date;
|
|
}
|
|
|
|
function endOfMonth(value) {
|
|
const date = startOfMonth(value);
|
|
date.setMonth(date.getMonth() + 1);
|
|
date.setDate(0);
|
|
return date;
|
|
}
|
|
|
|
function addDays(value, amount) {
|
|
const date = startOfDay(value);
|
|
date.setDate(date.getDate() + amount);
|
|
return date;
|
|
}
|
|
|
|
function addMonths(value, amount) {
|
|
const date = startOfMonth(value);
|
|
date.setMonth(date.getMonth() + amount);
|
|
return date;
|
|
}
|
|
|
|
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')}`;
|
|
}
|
|
|
|
function sortByDate(left, right) {
|
|
return left.scheduledAt.getTime() - right.scheduledAt.getTime();
|
|
}
|
|
|
|
function parseViewMode(value) {
|
|
return ['month', 'week', 'upcoming'].includes(value) ? value : 'month';
|
|
}
|
|
|
|
function parseCursorDate(value, fallback) {
|
|
if (typeof value !== 'string') {
|
|
return fallback;
|
|
}
|
|
|
|
const parsed = new Date(`${value}T00:00:00`);
|
|
return Number.isNaN(parsed.getTime()) ? fallback : startOfDay(parsed);
|
|
}
|
|
|
|
watch(
|
|
() => route.query,
|
|
query => {
|
|
viewMode.value = parseViewMode(query.view);
|
|
cursorDate.value = parseCursorDate(query.date, today);
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => [viewMode.value, dateKey(cursorDate.value)],
|
|
([view, date]) => {
|
|
if (route.query.view === view && route.query.date === date) {
|
|
return;
|
|
}
|
|
|
|
router.replace({
|
|
name: 'content-items',
|
|
query: {
|
|
...route.query,
|
|
view,
|
|
date,
|
|
},
|
|
});
|
|
}
|
|
);
|
|
|
|
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>
|
|
<section class="page-shell">
|
|
<div class="header">
|
|
<div>
|
|
<h1>{{ t('contentItems.title') }}</h1>
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<div
|
|
v-if="isLoading"
|
|
class="page-message"
|
|
>
|
|
{{ t('contentItems.loading') }}
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="pageError"
|
|
class="page-message error"
|
|
>
|
|
{{ pageError }}
|
|
</div>
|
|
|
|
<article
|
|
v-else-if="isCalendarView"
|
|
class="calendar-card"
|
|
>
|
|
<div class="calendar-toolbar">
|
|
<div class="calendar-nav">
|
|
<button
|
|
class="icon-button"
|
|
type="button"
|
|
@click="shiftPeriod(-1)"
|
|
>
|
|
<v-icon :icon="mdiChevronLeft" />
|
|
</button>
|
|
|
|
<div class="calendar-period">{{ periodLabel }}</div>
|
|
|
|
<button
|
|
class="icon-button"
|
|
type="button"
|
|
@click="shiftPeriod(1)"
|
|
>
|
|
<v-icon :icon="mdiChevronRight" />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
class="text-button"
|
|
type="button"
|
|
@click="jumpToToday"
|
|
>
|
|
{{ t('dashboard.today') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="calendar-grid calendar-grid-head">
|
|
<div
|
|
v-for="label in weekdayLabels"
|
|
:key="label"
|
|
class="weekday-label"
|
|
>
|
|
{{ label }}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="calendar-grid"
|
|
:class="viewMode === 'week' ? 'calendar-grid-week' : 'calendar-grid-month'"
|
|
>
|
|
<div
|
|
v-for="day in visibleDays"
|
|
:key="day.key"
|
|
class="calendar-day"
|
|
:class="{
|
|
'calendar-day-outside': day.isOutsideMonth,
|
|
'calendar-day-today': day.isToday,
|
|
'calendar-day-week': viewMode === 'week',
|
|
}"
|
|
>
|
|
<div class="day-number">
|
|
{{ formatDayNumber(day.date) }}
|
|
</div>
|
|
|
|
<div
|
|
v-if="day.entries.length"
|
|
class="day-entries"
|
|
>
|
|
<template
|
|
v-for="entry in viewMode === 'month' ? day.entries.slice(0, 3) : day.entries"
|
|
:key="`${entry.type}-${entry.id}`"
|
|
>
|
|
<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"
|
|
class="entry-more"
|
|
>
|
|
{{ t('dashboard.moreItems', { count: day.entries.length - 3 }) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="viewMode === 'week'"
|
|
class="day-empty"
|
|
>
|
|
{{ t('dashboard.emptyPeriod') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<div
|
|
v-else-if="upcomingEntries.length"
|
|
class="item-grid"
|
|
>
|
|
<template
|
|
v-for="entry in upcomingEntries"
|
|
:key="`${entry.type}-${entry.id}`"
|
|
>
|
|
<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
|
|
v-else
|
|
class="page-message"
|
|
>
|
|
{{ 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>
|
|
|
|
<style scoped>
|
|
.page-shell {
|
|
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
|
}
|
|
|
|
.header {
|
|
@apply flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between;
|
|
}
|
|
|
|
.header h1 {
|
|
@apply text-4xl font-black;
|
|
color: #172033;
|
|
}
|
|
|
|
.item-card span,
|
|
.status-row em,
|
|
.status-row small {
|
|
@apply text-sm leading-6 not-italic;
|
|
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 {
|
|
@apply inline-flex items-center justify-center rounded-full border px-3 py-2 text-sm font-semibold transition;
|
|
background: #f8fafc;
|
|
border-color: rgba(23, 32, 51, 0.1);
|
|
color: #172033;
|
|
}
|
|
|
|
.toggle-button {
|
|
@apply border-0 bg-transparent;
|
|
}
|
|
|
|
.toggle-button-active {
|
|
background: #172033;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.icon-button {
|
|
@apply h-10 w-10 px-0 py-0;
|
|
}
|
|
|
|
.icon-button:hover,
|
|
.text-button:hover,
|
|
.toggle-button:hover {
|
|
background: #eef4ff;
|
|
}
|
|
|
|
.page-message,
|
|
.item-card {
|
|
@apply rounded-[1.5rem] border;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-color: rgba(23, 32, 51, 0.08);
|
|
}
|
|
|
|
.page-message {
|
|
@apply p-5 text-sm;
|
|
color: #526178;
|
|
}
|
|
|
|
.page-message.error {
|
|
color: #b91c1c;
|
|
}
|
|
|
|
.calendar-card {
|
|
@apply rounded-[1.75rem] border p-4 md:p-5;
|
|
background: rgba(255, 255, 255, 0.94);
|
|
border-color: rgba(23, 32, 51, 0.08);
|
|
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
|
}
|
|
|
|
.calendar-toolbar {
|
|
@apply mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between;
|
|
}
|
|
|
|
.calendar-nav {
|
|
@apply flex items-center gap-2;
|
|
}
|
|
|
|
.calendar-period {
|
|
@apply min-w-0 px-2 text-base font-bold md:text-lg;
|
|
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));
|
|
}
|
|
|
|
.calendar-grid-head {
|
|
@apply mb-3;
|
|
}
|
|
|
|
.weekday-label {
|
|
@apply px-2 text-xs font-bold uppercase tracking-[0.16em];
|
|
color: #526178;
|
|
}
|
|
|
|
.calendar-day {
|
|
@apply min-h-[8.5rem] rounded-[1.25rem] border p-3;
|
|
background: linear-gradient(180deg, rgba(255, 253, 248, 0.8) 0%, rgba(255, 255, 255, 0.96) 100%);
|
|
border-color: rgba(23, 32, 51, 0.08);
|
|
}
|
|
|
|
.calendar-day-week {
|
|
@apply min-h-[22rem];
|
|
}
|
|
|
|
.calendar-day-outside {
|
|
opacity: 0.48;
|
|
}
|
|
|
|
.calendar-day-today {
|
|
border-color: rgba(15, 118, 110, 0.22);
|
|
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.18);
|
|
}
|
|
|
|
.day-number {
|
|
@apply mb-3 text-sm font-bold;
|
|
color: #172033;
|
|
}
|
|
|
|
.day-entries,
|
|
.item-card {
|
|
@apply flex flex-col gap-2;
|
|
}
|
|
|
|
.calendar-entry {
|
|
@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);
|
|
}
|
|
|
|
.calendar-entry strong,
|
|
.item-card strong {
|
|
@apply text-sm font-bold;
|
|
color: #172033;
|
|
}
|
|
|
|
.calendar-entry span {
|
|
@apply text-xs leading-5;
|
|
color: #526178;
|
|
}
|
|
|
|
.entry-time {
|
|
@apply text-[0.7rem] font-bold uppercase tracking-[0.12em];
|
|
color: #0f766e;
|
|
}
|
|
|
|
.entry-more,
|
|
.day-empty {
|
|
@apply px-1 text-xs font-semibold;
|
|
color: #526178;
|
|
}
|
|
|
|
.calendar-entry.production {
|
|
background: #fff7ed;
|
|
border-color: rgba(249, 115, 22, 0.18);
|
|
}
|
|
|
|
.calendar-entry.approval {
|
|
background: #eff6ff;
|
|
border-color: rgba(37, 99, 235, 0.16);
|
|
}
|
|
|
|
.calendar-entry.ready {
|
|
background: #ecfdf5;
|
|
border-color: rgba(5, 150, 105, 0.16);
|
|
}
|
|
|
|
.calendar-entry.risk {
|
|
background: #fef2f2;
|
|
border-color: rgba(220, 38, 38, 0.16);
|
|
}
|
|
|
|
.calendar-entry.campaign {
|
|
background: #f8fafc;
|
|
border-color: rgba(71, 85, 105, 0.18);
|
|
border-style: dashed;
|
|
}
|
|
|
|
.calendar-entry.published,
|
|
.calendar-entry.muted {
|
|
background: #f8fafc;
|
|
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;
|
|
}
|
|
|
|
.item-card {
|
|
@apply gap-4 p-5 no-underline transition;
|
|
}
|
|
|
|
.version-chip {
|
|
@apply w-fit rounded-full px-4 py-2 text-xs font-bold uppercase tracking-[0.16em];
|
|
background: rgba(23, 32, 51, 0.08);
|
|
color: #172033;
|
|
}
|
|
|
|
.status-row {
|
|
@apply flex items-center justify-between gap-3;
|
|
}
|
|
|
|
@media (max-width: 960px) {
|
|
.calendar-grid {
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.weekday-label {
|
|
@apply text-[0.65rem];
|
|
}
|
|
|
|
.calendar-day {
|
|
@apply min-h-[7rem] p-2;
|
|
}
|
|
|
|
.calendar-day-week {
|
|
@apply min-h-[18rem];
|
|
}
|
|
|
|
.calendar-entry {
|
|
@apply px-2 py-2;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.calendar-toolbar {
|
|
@apply items-stretch;
|
|
}
|
|
|
|
.calendar-nav {
|
|
@apply justify-between;
|
|
}
|
|
|
|
.calendar-grid-head,
|
|
.calendar-grid {
|
|
min-width: 46rem;
|
|
}
|
|
|
|
.calendar-card {
|
|
overflow-x: auto;
|
|
}
|
|
}
|
|
</style>
|