Major refactoring for cleanup, with various bugfixes. Weekday selector in Settings now shows workdays/weekend bars based on current locale (also used to choose default values). Weekday selector in event dialog uses the days set in settings, as expected.
982 lines
28 KiB
Vue
982 lines
28 KiB
Vue
<script setup>
|
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
|
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
|
import BaseDialog from './BaseDialog.vue'
|
|
import WeekdaySelector from './WeekdaySelector.vue'
|
|
import Numeric from './Numeric.vue'
|
|
import {
|
|
addDaysStr,
|
|
getMondayOfISOWeek,
|
|
fromLocalString,
|
|
formatDateShort,
|
|
formatDateLong,
|
|
DEFAULT_TZ,
|
|
} from '@/utils/date'
|
|
import { getDate as getOccurrenceDate } from '@/utils/events'
|
|
import { addDays, addMonths } from 'date-fns'
|
|
|
|
const props = defineProps({
|
|
selection: { type: Object, default: () => ({ startDate: null, dayCount: 0 }) },
|
|
})
|
|
|
|
const emit = defineEmits(['clear-selection'])
|
|
|
|
const calendarStore = useCalendarStore()
|
|
|
|
const showDialog = ref(false)
|
|
// Anchoring: element of the DayCell representing the event's start date.
|
|
const anchorElement = ref(null)
|
|
const dialogMode = ref('create') // 'create' or 'edit'
|
|
const editingEventId = ref(null)
|
|
const unsavedCreateId = ref(null)
|
|
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
|
|
const initialWeekday = ref(null)
|
|
const title = computed({
|
|
get() {
|
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
|
return calendarStore.events.get(editingEventId.value).title || ''
|
|
}
|
|
return ''
|
|
},
|
|
set(v) {
|
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
|
calendarStore.events.get(editingEventId.value).title = v
|
|
calendarStore.touchEvents()
|
|
}
|
|
},
|
|
})
|
|
const recurrenceEnabled = ref(false)
|
|
const recurrenceInterval = ref(1) // N in "Every N weeks/months"
|
|
const recurrenceFrequency = ref('weeks') // 'weeks' | 'months'
|
|
const recurrenceWeekdays = ref([false, false, false, false, false, false, false])
|
|
const recurrenceOccurrences = ref(0) // 0 = unlimited
|
|
const colorId = ref(0)
|
|
const eventSaved = ref(false)
|
|
const titleInput = ref(null)
|
|
|
|
const uiDisplayFrequency = ref('weeks') // 'weeks' | 'months' | 'years'
|
|
|
|
// Helper to get starting weekday (Sunday-first index)
|
|
function getStartingWeekday(selectionData = null) {
|
|
const currentSelection = selectionData || props.selection
|
|
if (!currentSelection.start) return 0
|
|
const date = fromLocalString(currentSelection.start, DEFAULT_TZ)
|
|
return date.getDay() // 0=Sunday, 6=Saturday
|
|
}
|
|
|
|
const fallbackWeekdays = computed(() => {
|
|
let weekday = initialWeekday.value
|
|
if (weekday == null) {
|
|
weekday = getStartingWeekday()
|
|
}
|
|
const fb = [false, false, false, false, false, false, false]
|
|
fb[weekday] = true
|
|
return fb
|
|
})
|
|
|
|
// Maps UI frequency display (including years) to store frequency (weeks/months only)
|
|
const displayFrequency = computed({
|
|
get() {
|
|
return uiDisplayFrequency.value
|
|
},
|
|
set(val) {
|
|
const oldFreq = uiDisplayFrequency.value
|
|
const currentDisplayValue = displayInterval.value
|
|
uiDisplayFrequency.value = val
|
|
|
|
if (oldFreq === val) return
|
|
|
|
if (oldFreq === 'years' && val === 'months') {
|
|
recurrenceFrequency.value = 'months'
|
|
recurrenceInterval.value = currentDisplayValue
|
|
} else if (oldFreq === 'months' && val === 'years') {
|
|
recurrenceFrequency.value = 'months'
|
|
recurrenceInterval.value = currentDisplayValue * 12
|
|
} else if (val === 'years') {
|
|
recurrenceFrequency.value = 'months'
|
|
recurrenceInterval.value = 12
|
|
} else if (val === 'months') {
|
|
recurrenceFrequency.value = 'months'
|
|
if (oldFreq === 'weeks') {
|
|
recurrenceInterval.value = 1
|
|
}
|
|
} else if (val === 'weeks') {
|
|
recurrenceFrequency.value = 'weeks'
|
|
recurrenceInterval.value = 1
|
|
}
|
|
},
|
|
})
|
|
|
|
// Computed property for display interval (handles years conversion)
|
|
const displayInterval = computed({
|
|
get() {
|
|
if (uiDisplayFrequency.value === 'years') {
|
|
return recurrenceInterval.value / 12
|
|
}
|
|
return recurrenceInterval.value
|
|
},
|
|
set(val) {
|
|
if (uiDisplayFrequency.value === 'years') {
|
|
recurrenceInterval.value = val * 12
|
|
} else {
|
|
recurrenceInterval.value = val
|
|
}
|
|
},
|
|
})
|
|
|
|
const selectedColor = computed({
|
|
get() {
|
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
|
return calendarStore.events.get(editingEventId.value).colorId
|
|
}
|
|
return colorId.value
|
|
},
|
|
set(v) {
|
|
const n = parseInt(v)
|
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
|
calendarStore.events.get(editingEventId.value).colorId = n
|
|
calendarStore.touchEvents()
|
|
}
|
|
colorId.value = n
|
|
},
|
|
})
|
|
|
|
// Maps 0 (unlimited) to 'unlimited' string in store
|
|
const repeatCountBinding = computed({
|
|
get() {
|
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
|
const ev = calendarStore.events.get(editingEventId.value)
|
|
const rc = ev.recur?.count ?? 'unlimited'
|
|
return rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
|
}
|
|
return recurrenceOccurrences.value
|
|
},
|
|
set(v) {
|
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
|
const ev = calendarStore.events.get(editingEventId.value)
|
|
if (!ev.recur && v !== 0) {
|
|
ev.recur = {
|
|
freq: recurrenceFrequency.value,
|
|
interval: recurrenceInterval.value,
|
|
count: 'unlimited',
|
|
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
|
|
}
|
|
}
|
|
if (ev.recur) ev.recur.count = v === 0 ? 'unlimited' : String(v)
|
|
calendarStore.touchEvents()
|
|
}
|
|
recurrenceOccurrences.value = v
|
|
},
|
|
})
|
|
|
|
const repeat = computed({
|
|
get() {
|
|
if (!recurrenceEnabled.value) return 'none'
|
|
return recurrenceFrequency.value
|
|
},
|
|
set(val) {
|
|
if (val === 'none') {
|
|
recurrenceEnabled.value = false
|
|
return
|
|
}
|
|
recurrenceEnabled.value = true
|
|
if (val === 'weeks') {
|
|
recurrenceFrequency.value = 'weeks'
|
|
uiDisplayFrequency.value = 'weeks'
|
|
if (recurrenceInterval.value >= 12) {
|
|
recurrenceInterval.value = 1 // Reset to sensible weekly default
|
|
}
|
|
} else if (val === 'months') {
|
|
recurrenceFrequency.value = 'months'
|
|
}
|
|
},
|
|
})
|
|
|
|
function buildStoreWeekdayPattern() {
|
|
return [...recurrenceWeekdays.value]
|
|
}
|
|
|
|
function loadWeekdayPatternFromStore(storePattern) {
|
|
if (!Array.isArray(storePattern) || storePattern.length !== 7) return
|
|
recurrenceWeekdays.value = [...storePattern]
|
|
}
|
|
|
|
function resolveAnchorFromDate(dateStr) {
|
|
if (!dateStr) return null
|
|
// Expect day cells to have data-date attribute (see CalendarDay / DayCell components)
|
|
return document.querySelector(`[data-date='${dateStr}']`)
|
|
}
|
|
|
|
function openCreateDialog(selectionData = null) {
|
|
calendarStore.$history?.beginCompound()
|
|
if (unsavedCreateId.value && !eventSaved.value) {
|
|
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
|
}
|
|
unsavedCreateId.value = null
|
|
}
|
|
const currentSelection = selectionData || props.selection
|
|
|
|
let start, end
|
|
if (currentSelection.startDate && currentSelection.dayCount) {
|
|
start = currentSelection.startDate
|
|
end = addDaysStr(currentSelection.startDate, currentSelection.dayCount - 1)
|
|
} else if (currentSelection.start && currentSelection.end) {
|
|
start = currentSelection.start
|
|
end = currentSelection.end
|
|
} else {
|
|
start = null
|
|
end = null
|
|
}
|
|
|
|
occurrenceContext.value = null
|
|
initialWeekday.value = null
|
|
dialogMode.value = 'create'
|
|
recurrenceEnabled.value = false
|
|
recurrenceInterval.value = 1
|
|
recurrenceFrequency.value = 'weeks'
|
|
uiDisplayFrequency.value = 'weeks'
|
|
recurrenceWeekdays.value = [false, false, false, false, false, false, false]
|
|
recurrenceOccurrences.value = 0
|
|
colorId.value = calendarStore.selectEventColorId(start, end)
|
|
eventSaved.value = false
|
|
|
|
const startingDay = getStartingWeekday({ start, end })
|
|
recurrenceWeekdays.value[startingDay] = true
|
|
initialWeekday.value = startingDay
|
|
|
|
let days = 1
|
|
if (start && end && start <= end) {
|
|
const s = fromLocalString(start, DEFAULT_TZ)
|
|
const e = fromLocalString(end, DEFAULT_TZ)
|
|
days = Math.max(1, (e - s) / 86400000 + 1)
|
|
}
|
|
editingEventId.value = calendarStore.createEvent({
|
|
title: '',
|
|
startDate: start,
|
|
days,
|
|
colorId: colorId.value,
|
|
recur:
|
|
recurrenceEnabled.value && repeat.value !== 'none'
|
|
? {
|
|
freq: recurrenceFrequency.value,
|
|
interval: recurrenceInterval.value,
|
|
count:
|
|
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value),
|
|
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
|
|
}
|
|
: null,
|
|
})
|
|
unsavedCreateId.value = editingEventId.value
|
|
|
|
// anchor to the starting day cell
|
|
anchorElement.value = resolveAnchorFromDate(start)
|
|
showDialog.value = true
|
|
|
|
nextTick(() => {
|
|
if (titleInput.value) {
|
|
titleInput.value.focus()
|
|
if (title.value) {
|
|
titleInput.value.select()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
function openEditDialog(payload) {
|
|
calendarStore.$history?.beginCompound()
|
|
if (
|
|
dialogMode.value === 'create' &&
|
|
unsavedCreateId.value &&
|
|
!eventSaved.value &&
|
|
payload &&
|
|
unsavedCreateId.value !== payload.id
|
|
) {
|
|
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
|
}
|
|
unsavedCreateId.value = null
|
|
}
|
|
occurrenceContext.value = null
|
|
initialWeekday.value = null
|
|
if (!payload) return
|
|
|
|
const baseId = payload.id
|
|
let n = payload.n || 0
|
|
let weekday = null
|
|
let occurrenceDate = null
|
|
|
|
const event = calendarStore.getEventById(baseId)
|
|
if (!event) return
|
|
|
|
if (event.recur && n >= 0) {
|
|
const occStr = getOccurrenceDate(event, n, DEFAULT_TZ)
|
|
if (occStr) {
|
|
occurrenceDate = fromLocalString(occStr, DEFAULT_TZ)
|
|
weekday = occurrenceDate.getDay()
|
|
}
|
|
}
|
|
dialogMode.value = 'edit'
|
|
editingEventId.value = baseId
|
|
loadWeekdayPatternFromStore(event.recur?.weekdays)
|
|
initialWeekday.value =
|
|
weekday != null ? weekday : fromLocalString(event.startDate, DEFAULT_TZ).getDay()
|
|
repeat.value = event.recur ? event.recur.freq : 'none'
|
|
if (event.recur?.interval) recurrenceInterval.value = event.recur.interval
|
|
|
|
// Set UI display frequency based on loaded data
|
|
if (event.recur?.freq === 'weeks') {
|
|
uiDisplayFrequency.value = 'weeks'
|
|
} else if (event.recur?.freq === 'months') {
|
|
if (event.recur.interval && event.recur.interval % 12 === 0 && event.recur.interval >= 12) {
|
|
uiDisplayFrequency.value = 'years'
|
|
} else {
|
|
uiDisplayFrequency.value = 'months'
|
|
}
|
|
} else {
|
|
uiDisplayFrequency.value = 'weeks'
|
|
}
|
|
|
|
const rc = event.recur?.count ?? 'unlimited'
|
|
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
|
colorId.value = event.colorId
|
|
eventSaved.value = false
|
|
|
|
if (event.recur) {
|
|
if (event.recur.freq === 'weeks' && n >= 0) {
|
|
occurrenceContext.value = { baseId, occurrenceIndex: n, weekday, occurrenceDate }
|
|
} else if (event.recur.freq === 'months' && n > 0) {
|
|
occurrenceContext.value = { baseId, occurrenceIndex: n, weekday: null, occurrenceDate }
|
|
}
|
|
}
|
|
// anchor to base event start date
|
|
anchorElement.value = resolveAnchorFromDate(event.startDate)
|
|
showDialog.value = true
|
|
|
|
nextTick(() => {
|
|
if (titleInput.value) {
|
|
titleInput.value.focus()
|
|
if (title.value) {
|
|
titleInput.value.select()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
function closeDialog() {
|
|
calendarStore.$history?.endCompound()
|
|
showDialog.value = false
|
|
}
|
|
|
|
function updateEventInStore() {
|
|
if (!editingEventId.value) return
|
|
|
|
if (calendarStore.events?.has(editingEventId.value)) {
|
|
const event = calendarStore.events.get(editingEventId.value)
|
|
event.colorId = colorId.value
|
|
if (recurrenceEnabled.value && repeat.value !== 'none') {
|
|
event.recur = {
|
|
freq: recurrenceFrequency.value,
|
|
interval: recurrenceInterval.value,
|
|
count:
|
|
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value),
|
|
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
|
|
}
|
|
} else {
|
|
event.recur = null
|
|
}
|
|
calendarStore.touchEvents()
|
|
}
|
|
}
|
|
|
|
function saveEvent() {
|
|
if (editingEventId.value) updateEventInStore()
|
|
eventSaved.value = true
|
|
if (dialogMode.value === 'create') {
|
|
unsavedCreateId.value = null
|
|
}
|
|
if (dialogMode.value === 'create') emit('clear-selection')
|
|
calendarStore.$history?.endCompound()
|
|
closeDialog()
|
|
}
|
|
|
|
function deleteEventAll() {
|
|
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
|
calendarStore.$history?.endCompound()
|
|
closeDialog()
|
|
}
|
|
|
|
function deleteEventOne() {
|
|
if (occurrenceContext.value) {
|
|
calendarStore.deleteSingleOccurrence(occurrenceContext.value)
|
|
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
|
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
|
}
|
|
calendarStore.$history?.endCompound()
|
|
closeDialog()
|
|
}
|
|
|
|
function deleteEventFrom() {
|
|
if (!occurrenceContext.value) return
|
|
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
|
calendarStore.$history?.endCompound()
|
|
closeDialog()
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
if (unsavedCreateId.value && !eventSaved.value) {
|
|
if (calendarStore.events?.has(unsavedCreateId.value))
|
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
|
}
|
|
})
|
|
|
|
watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
|
|
if (editingEventId.value && showDialog.value) updateEventInStore()
|
|
})
|
|
watch(showDialog, (val, oldVal) => {
|
|
if (oldVal && !val) {
|
|
// Closed (cancel, escape, outside click) -> end compound session
|
|
calendarStore.$history?.endCompound()
|
|
if (dialogMode.value === 'create' && unsavedCreateId.value && !eventSaved.value) {
|
|
if (calendarStore.events?.has(unsavedCreateId.value)) {
|
|
calendarStore.deleteEvent(unsavedCreateId.value)
|
|
}
|
|
}
|
|
editingEventId.value = null
|
|
unsavedCreateId.value = null
|
|
}
|
|
})
|
|
watch(
|
|
recurrenceWeekdays,
|
|
() => {
|
|
if (editingEventId.value && showDialog.value && repeat.value === 'weeks') updateEventInStore()
|
|
},
|
|
{ deep: true },
|
|
)
|
|
|
|
defineExpose({
|
|
openCreateDialog,
|
|
openEditDialog,
|
|
closeDialog,
|
|
})
|
|
|
|
const isRepeatingEdit = computed(
|
|
() => dialogMode.value === 'edit' && recurrenceEnabled.value && repeat.value !== 'none',
|
|
)
|
|
const showDeleteVariants = computed(() => isRepeatingEdit.value && occurrenceContext.value)
|
|
const isRepeatingBaseEdit = computed(() => isRepeatingEdit.value && !occurrenceContext.value)
|
|
const isLastOccurrence = computed(() => {
|
|
if (!occurrenceContext.value || !editingEventId.value) return false
|
|
const event = calendarStore.getEventById(editingEventId.value)
|
|
if (!event || !event.recur) return false
|
|
if (event.recur.count === 'unlimited' || recurrenceOccurrences.value === 0) return false
|
|
const totalCount = parseInt(event.recur.count, 10) || 0
|
|
return occurrenceContext.value.occurrenceIndex === totalCount - 1
|
|
})
|
|
const formattedOccurrenceShort = computed(() => {
|
|
if (occurrenceContext.value?.occurrenceDate) {
|
|
return formatDateShort(occurrenceContext.value.occurrenceDate)
|
|
}
|
|
if (isRepeatingBaseEdit.value && editingEventId.value) {
|
|
const ev = calendarStore.getEventById(editingEventId.value)
|
|
if (ev?.startDate) {
|
|
return formatDateShort(fromLocalString(ev.startDate, DEFAULT_TZ))
|
|
}
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const headerDateShort = computed(() => {
|
|
if (occurrenceContext.value?.occurrenceDate) {
|
|
return formatDateShort(occurrenceContext.value.occurrenceDate)
|
|
}
|
|
if (editingEventId.value) {
|
|
const ev = calendarStore.getEventById(editingEventId.value)
|
|
if (ev?.startDate) {
|
|
return formatDateShort(fromLocalString(ev.startDate, DEFAULT_TZ))
|
|
}
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const finalOccurrenceDate = computed(() => {
|
|
if (!recurrenceEnabled.value) return null
|
|
const count = recurrenceOccurrences.value
|
|
if (!count || count < 1) return null
|
|
const base = editingEventId.value ? calendarStore.getEventById(editingEventId.value) : null
|
|
if (!base) return null
|
|
const start = fromLocalString(base.startDate, DEFAULT_TZ)
|
|
if (uiDisplayFrequency.value === 'weeks') {
|
|
// iterate days until we count 'count-1' additional occurrences (first is base if selected weekday)
|
|
const pattern = buildStoreWeekdayPattern() // Sun..Sat
|
|
// Build Monday-first pattern again for selection clarity
|
|
const monFirst = recurrenceWeekdays.value
|
|
const selectedCount = monFirst.some(Boolean)
|
|
if (!selectedCount) return null
|
|
let occs = 0
|
|
// Determine if the start day counts
|
|
const startWeekdaySun = start.getDay()
|
|
// Convert to Monday-first index
|
|
// We'll just check store pattern
|
|
if (pattern[startWeekdaySun]) occs = 1
|
|
let cursor = new Date(start)
|
|
while (occs < count && occs < 10000) {
|
|
cursor = addDays(cursor, 1)
|
|
if (pattern[cursor.getDay()]) occs++
|
|
}
|
|
if (occs === count) return cursor
|
|
return null
|
|
} else if (uiDisplayFrequency.value === 'months') {
|
|
const monthsToAdd = displayInterval.value * (count - 1)
|
|
return addMonths(start, monthsToAdd)
|
|
} else if (uiDisplayFrequency.value === 'years') {
|
|
const yearsToAdd = displayInterval.value * (count - 1)
|
|
const d = new Date(start)
|
|
d.setFullYear(d.getFullYear() + yearsToAdd)
|
|
return d
|
|
}
|
|
})
|
|
|
|
const formattedFinalOccurrence = computed(() => {
|
|
const d = finalOccurrenceDate.value
|
|
if (!d) return ''
|
|
const now = new Date()
|
|
const includeYear =
|
|
d.getFullYear() !== now.getFullYear() ||
|
|
d.getTime() - now.getTime() >= 1000 * 60 * 60 * 24 * 365
|
|
return formatDateLong(d, includeYear)
|
|
})
|
|
|
|
const recurrenceSummary = computed(() => {
|
|
if (!recurrenceEnabled.value) return 'Does not recur'
|
|
if (uiDisplayFrequency.value === 'weeks') {
|
|
return displayInterval.value === 1 ? 'Weekly' : `Every ${displayInterval.value} weeks`
|
|
} else if (uiDisplayFrequency.value === 'years') {
|
|
return displayInterval.value === 1 ? 'Annually' : `Every ${displayInterval.value} years`
|
|
}
|
|
if (recurrenceFrequency.value === 'months' && recurrenceInterval.value % 12 === 0) {
|
|
const years = recurrenceInterval.value / 12
|
|
return years === 1 ? 'Annually' : `Every ${years} years`
|
|
}
|
|
return displayInterval.value === 1 ? 'Monthly' : `Every ${displayInterval.value} months`
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<BaseDialog v-model="showDialog" :anchor-el="anchorElement" @submit="saveEvent">
|
|
<template #title>
|
|
<div class="dialog-title-row">
|
|
{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' }}
|
|
<span> · {{ headerDateShort }}</span>
|
|
</div>
|
|
</template>
|
|
<label class="ec-field">
|
|
<input type="text" v-model="title" autocomplete="off" ref="titleInput" />
|
|
</label>
|
|
<div class="ec-color-swatches">
|
|
<label v-for="i in 8" :key="i - 1" class="swatch-label">
|
|
<input
|
|
class="swatch"
|
|
:class="'event-color-' + (i - 1)"
|
|
type="radio"
|
|
name="colorId"
|
|
:value="i - 1"
|
|
v-model="selectedColor"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div class="recurrence-block">
|
|
<div class="recurrence-header">
|
|
<label class="switch">
|
|
<input type="checkbox" v-model="recurrenceEnabled" />
|
|
<span>Repeat</span>
|
|
</label>
|
|
<span class="recurrence-summary" v-if="recurrenceEnabled">
|
|
{{ recurrenceSummary }}
|
|
<span v-if="recurrenceOccurrences > 0"> until {{ formattedFinalOccurrence }} </span>
|
|
</span>
|
|
<span class="recurrence-summary muted" v-else>Does not recur</span>
|
|
</div>
|
|
<div v-if="recurrenceEnabled" class="recurrence-form">
|
|
<div class="line compact">
|
|
<Numeric
|
|
v-model="displayInterval"
|
|
:prefix-values="[{ value: 1, display: 'Every' }]"
|
|
:min="2"
|
|
number-prefix="Every "
|
|
aria-label="Interval"
|
|
/>
|
|
<select v-model="displayFrequency" class="freq-select">
|
|
<option value="weeks">{{ displayInterval === 1 ? 'week' : 'weeks' }}</option>
|
|
<option value="months">{{ displayInterval === 1 ? 'month' : 'months' }}</option>
|
|
<option value="years">{{ displayInterval === 1 ? 'year' : 'years' }}</option>
|
|
</select>
|
|
<Numeric
|
|
class="occ-stepper"
|
|
v-model="repeatCountBinding"
|
|
:min="2"
|
|
:prefix-values="[{ value: 0, display: '∞' }]"
|
|
number-postfix=" times"
|
|
aria-label="Occurrences (0 = no end)"
|
|
extra-class="occ"
|
|
/>
|
|
</div>
|
|
<div v-if="uiDisplayFrequency === 'weeks'" @click.stop>
|
|
<WeekdaySelector
|
|
v-model="recurrenceWeekdays"
|
|
:fallback="fallbackWeekdays"
|
|
:first-day="calendarStore.config.first_day"
|
|
:weekend="calendarStore.weekend"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<template v-if="dialogMode === 'create'">
|
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">Delete</button>
|
|
<button type="submit" class="ec-btn save-btn">Save</button>
|
|
</template>
|
|
<template v-else>
|
|
<template v-if="showDeleteVariants">
|
|
<div class="ec-delete-group">
|
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
|
|
Delete <span>{{ formattedOccurrenceShort }}</span>
|
|
</button>
|
|
<button
|
|
v-if="!isLastOccurrence"
|
|
type="button"
|
|
class="ec-btn delete-btn"
|
|
@click="deleteEventFrom"
|
|
>
|
|
+ Rest
|
|
</button>
|
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
|
</div>
|
|
</template>
|
|
<template v-else-if="isRepeatingBaseEdit">
|
|
<div class="ec-delete-group">
|
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">
|
|
Delete {{ formattedOccurrenceShort }}
|
|
</button>
|
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">Delete</button>
|
|
</template>
|
|
<button type="button" class="ec-btn close-btn" @click="closeDialog">Close</button>
|
|
</template>
|
|
</template>
|
|
</BaseDialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.ec-field {
|
|
display: grid;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.ec-field > span {
|
|
font-size: 0.85em;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.ec-field input[type='text'],
|
|
.ec-field input[type='time'],
|
|
.ec-field input[type='number'],
|
|
.ec-field select {
|
|
border: 1px solid var(--muted);
|
|
border-radius: 0.4rem;
|
|
padding: 0.5rem 0.6rem;
|
|
width: 100%;
|
|
background: transparent;
|
|
color: var(--ink);
|
|
}
|
|
|
|
.ec-color-swatches {
|
|
display: grid;
|
|
grid-template-columns: repeat(8, 1fr);
|
|
}
|
|
|
|
.ec-color-swatches .swatch {
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 0.4em;
|
|
padding: 0.25em;
|
|
outline: 0.125em solid transparent;
|
|
outline-offset: 0.125em;
|
|
cursor: pointer;
|
|
appearance: none;
|
|
width: 80%;
|
|
height: 1em;
|
|
}
|
|
|
|
.ec-color-swatches .swatch:checked {
|
|
outline-color: var(--ink);
|
|
}
|
|
|
|
.ec-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.75em;
|
|
}
|
|
|
|
.ec-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--ink);
|
|
padding: 0.5em 0.8em;
|
|
border-radius: 0.4em;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.ec-btn:hover {
|
|
background: var(--muted);
|
|
}
|
|
|
|
.ec-btn.save-btn {
|
|
background: var(--today);
|
|
color: #000;
|
|
border-color: transparent;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.ec-btn.save-btn:hover {
|
|
background: color-mix(in srgb, var(--today) 90%, black);
|
|
}
|
|
|
|
.ec-btn.close-btn {
|
|
background: var(--panel);
|
|
border-color: var(--muted);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.ec-btn.close-btn:hover {
|
|
background: var(--muted);
|
|
}
|
|
|
|
.ec-btn.delete-btn {
|
|
background: hsl(0, 70%, 50%);
|
|
color: #fff;
|
|
border-color: transparent;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.ec-btn.delete-btn:hover {
|
|
background: hsl(0, 70%, 45%);
|
|
}
|
|
|
|
.ec-weekday-selector {
|
|
display: grid;
|
|
gap: 0.5em;
|
|
}
|
|
|
|
.ec-field-label {
|
|
font-size: 0.85em;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.ec-weekdays {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
gap: 0.25em;
|
|
}
|
|
|
|
.ec-weekday-label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
cursor: pointer;
|
|
padding: 0.5rem 0.25rem;
|
|
border-radius: 0.3rem;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
.ec-weekday-label:hover {
|
|
background: var(--muted);
|
|
}
|
|
|
|
.ec-weekday-checkbox {
|
|
margin: 0;
|
|
}
|
|
|
|
.ec-weekday-text {
|
|
font-size: 0.8em;
|
|
font-weight: 500;
|
|
text-align: center;
|
|
}
|
|
|
|
/* New recurrence block */
|
|
.recurrence-block {
|
|
display: grid;
|
|
gap: 0.6em;
|
|
}
|
|
.recurrence-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75em;
|
|
}
|
|
.recurrence-header .recurrence-summary {
|
|
font-size: 0.75rem;
|
|
color: var(--ink);
|
|
opacity: 0.85;
|
|
}
|
|
.recurrence-header .recurrence-summary.muted {
|
|
opacity: 0.5;
|
|
}
|
|
.switch {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
}
|
|
.switch input {
|
|
width: 1rem;
|
|
height: 1rem;
|
|
}
|
|
.recurrence-form {
|
|
display: grid;
|
|
gap: 0.6em;
|
|
padding: 0.6em 0.75em 0.75em;
|
|
border: 0.0625em solid var(--muted);
|
|
border-radius: 0.5em;
|
|
background: color-mix(in srgb, var(--muted) 15%, transparent);
|
|
}
|
|
.line.compact {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5em;
|
|
flex-wrap: wrap;
|
|
font-size: 0.75em;
|
|
}
|
|
.freq-select {
|
|
padding: 0.4rem 0.55rem;
|
|
font-size: 0.75rem;
|
|
border: 1px solid var(--input-border);
|
|
background: var(--panel-alt);
|
|
color: var(--ink);
|
|
border-radius: 0.45rem;
|
|
transition:
|
|
border-color 0.18s ease,
|
|
background-color 0.18s ease;
|
|
}
|
|
.freq-select:focus {
|
|
outline: none;
|
|
border-color: var(--input-focus);
|
|
background: var(--panel-accent);
|
|
color: var(--ink);
|
|
box-shadow:
|
|
0 0 0 1px var(--input-focus),
|
|
0 0 0 4px rgba(37, 99, 235, 0.15);
|
|
}
|
|
.interval-input,
|
|
.occ-input {
|
|
display: none;
|
|
}
|
|
.ec-field input[type='text'] {
|
|
border: 1px solid var(--input-border);
|
|
background: var(--panel-alt);
|
|
border-radius: 0.45rem;
|
|
padding: 0.4rem 0.5rem;
|
|
transition:
|
|
border-color 0.18s ease,
|
|
background-color 0.18s ease,
|
|
box-shadow 0.18s ease;
|
|
}
|
|
.ec-field input[type='text']:focus {
|
|
outline: none;
|
|
border-color: var(--input-focus);
|
|
background: var(--panel-accent);
|
|
box-shadow:
|
|
0 0 0 1px var(--input-focus),
|
|
0 0 0 4px rgba(37, 99, 235, 0.15);
|
|
}
|
|
.hint {
|
|
font-size: 0.65rem;
|
|
opacity: 0.65;
|
|
}
|
|
|
|
/* Recurrence UI */
|
|
.ec-recurrence-section {
|
|
display: grid;
|
|
gap: 0.4rem;
|
|
}
|
|
.ec-recurrence-toggle {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
width: 100%;
|
|
padding: 0.6rem 0.8rem;
|
|
border: 1px solid var(--muted);
|
|
background: var(--panel);
|
|
border-radius: 0.4rem;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
text-align: start;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
.ec-recurrence-toggle:hover {
|
|
background: var(--muted);
|
|
}
|
|
.ec-recurrence-toggle .toggle-icon {
|
|
transition: transform 0.2s ease;
|
|
font-size: 0.7rem;
|
|
color: var(--muted);
|
|
}
|
|
.ec-recurrence-toggle .toggle-icon.open {
|
|
transform: rotate(180deg);
|
|
}
|
|
.ec-recurrence-panel {
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
padding: 0.6rem;
|
|
border: 1px solid var(--muted);
|
|
border-radius: 0.4rem;
|
|
background: color-mix(in srgb, var(--muted) 20%, transparent);
|
|
}
|
|
|
|
/* Repeat modes */
|
|
.ec-repeat-modes {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem;
|
|
}
|
|
.ec-repeat-modes .mode-btn {
|
|
flex: 1 1 auto;
|
|
padding: 0.4rem 0.6rem;
|
|
border: 1px solid var(--muted);
|
|
background: var(--panel);
|
|
border-radius: 0.4rem;
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
line-height: 1.1;
|
|
white-space: nowrap;
|
|
transition:
|
|
background-color 0.15s ease,
|
|
color 0.15s ease,
|
|
border-color 0.15s ease;
|
|
}
|
|
.ec-repeat-modes .mode-btn.active {
|
|
background: var(--today);
|
|
color: #000;
|
|
border-color: var(--today);
|
|
font-weight: 600;
|
|
}
|
|
.ec-repeat-modes .mode-btn:hover {
|
|
background: var(--muted);
|
|
}
|
|
|
|
.ec-occurrences-field {
|
|
margin-top: 0.2rem;
|
|
}
|
|
.ec-occurrences-field .ec-field input[type='number'] {
|
|
max-width: 6rem;
|
|
}
|
|
span {
|
|
unicode-bidi: isolate;
|
|
}
|
|
</style>
|