1012 lines
28 KiB
Vue
1012 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 { 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)
|
|
const dialogMode = ref('create') // 'create' or 'edit'
|
|
const editingEventId = ref(null)
|
|
const unsavedCreateId = ref(null)
|
|
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
|
|
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
|
|
}
|
|
},
|
|
})
|
|
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(() => {
|
|
const startingDay = getStartingWeekday()
|
|
const fallback = [false, false, false, false, false, false, false]
|
|
fallback[startingDay] = true
|
|
return fallback
|
|
})
|
|
|
|
// 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
|
|
}
|
|
colorId.value = n
|
|
},
|
|
})
|
|
|
|
// Maps 0 (unlimited) to 'unlimited' string in store
|
|
const repeatCountBinding = computed({
|
|
get() {
|
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
|
const rc = calendarStore.events.get(editingEventId.value).repeatCount
|
|
return rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
|
}
|
|
return recurrenceOccurrences.value
|
|
},
|
|
set(v) {
|
|
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
|
|
calendarStore.events.get(editingEventId.value).repeatCount = v === 0 ? 'unlimited' : String(v)
|
|
}
|
|
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() {
|
|
let sunFirst = [...recurrenceWeekdays.value]
|
|
|
|
if (!sunFirst.some(Boolean)) {
|
|
const startingDay = getStartingWeekday()
|
|
sunFirst[startingDay] = true
|
|
}
|
|
|
|
return sunFirst
|
|
}
|
|
|
|
function loadWeekdayPatternFromStore(storePattern) {
|
|
if (!Array.isArray(storePattern) || storePattern.length !== 7) return
|
|
recurrenceWeekdays.value = [...storePattern]
|
|
}
|
|
|
|
function openCreateDialog(selectionData = null) {
|
|
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
|
|
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
|
|
|
|
editingEventId.value = calendarStore.createEvent({
|
|
title: '',
|
|
startDate: start,
|
|
endDate: end,
|
|
colorId: colorId.value,
|
|
repeat: repeat.value,
|
|
repeatInterval: recurrenceInterval.value,
|
|
repeatCount:
|
|
recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value),
|
|
repeatWeekdays: buildStoreWeekdayPattern(),
|
|
})
|
|
unsavedCreateId.value = editingEventId.value
|
|
|
|
showDialog.value = true
|
|
|
|
nextTick(() => {
|
|
if (titleInput.value) {
|
|
titleInput.value.focus()
|
|
if (title.value) {
|
|
titleInput.value.select()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
function openEditDialog(payload) {
|
|
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
|
|
if (!payload) return
|
|
|
|
const baseId = payload.id
|
|
let occurrenceIndex = payload.occurrenceIndex || 0
|
|
let weekday = null
|
|
let occurrenceDate = null
|
|
|
|
const event = calendarStore.getEventById(baseId)
|
|
if (!event) return
|
|
|
|
if (event.isRepeating) {
|
|
if (event.repeat === 'weeks' && occurrenceIndex >= 0) {
|
|
const pattern = event.repeatWeekdays || []
|
|
const baseStart = fromLocalString(event.startDate, DEFAULT_TZ)
|
|
const baseEnd = fromLocalString(event.endDate, DEFAULT_TZ)
|
|
if (occurrenceIndex === 0) {
|
|
occurrenceDate = baseStart
|
|
weekday = baseStart.getDay()
|
|
} else {
|
|
const interval = event.repeatInterval || 1
|
|
const WEEK_MS = 7 * 86400000
|
|
const baseBlockStart = getMondayOfISOWeek(baseStart)
|
|
function isAligned(d) {
|
|
const blk = getMondayOfISOWeek(d)
|
|
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
|
|
return diff % interval === 0
|
|
}
|
|
let cur = addDays(baseEnd, 1)
|
|
let found = 0
|
|
let safety = 0
|
|
while (found < occurrenceIndex && safety < 20000) {
|
|
if (pattern[cur.getDay()] && isAligned(cur)) {
|
|
found++
|
|
if (found === occurrenceIndex) break
|
|
}
|
|
cur = addDays(cur, 1)
|
|
safety++
|
|
}
|
|
occurrenceDate = cur
|
|
weekday = cur.getDay()
|
|
}
|
|
} else if (event.repeat === 'months' && occurrenceIndex >= 0) {
|
|
const baseDate = fromLocalString(event.startDate, DEFAULT_TZ)
|
|
occurrenceDate = addMonths(baseDate, occurrenceIndex)
|
|
}
|
|
}
|
|
dialogMode.value = 'edit'
|
|
editingEventId.value = baseId
|
|
loadWeekdayPatternFromStore(event.repeatWeekdays)
|
|
repeat.value = event.repeat // triggers setter mapping into recurrence state
|
|
if (event.repeatInterval) recurrenceInterval.value = event.repeatInterval
|
|
|
|
// Set UI display frequency based on loaded data
|
|
if (event.repeat === 'weeks') {
|
|
uiDisplayFrequency.value = 'weeks'
|
|
} else if (event.repeat === 'months') {
|
|
if (event.repeatInterval && event.repeatInterval % 12 === 0 && event.repeatInterval >= 12) {
|
|
uiDisplayFrequency.value = 'years'
|
|
} else {
|
|
uiDisplayFrequency.value = 'months'
|
|
}
|
|
} else {
|
|
uiDisplayFrequency.value = 'weeks'
|
|
}
|
|
|
|
const rc = event.repeatCount ?? 'unlimited'
|
|
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
|
colorId.value = event.colorId
|
|
eventSaved.value = false
|
|
|
|
if (event.isRepeating) {
|
|
if (event.repeat === 'weeks' && occurrenceIndex >= 0) {
|
|
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
|
|
} else if (event.repeat === 'months' && occurrenceIndex > 0) {
|
|
occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate }
|
|
}
|
|
}
|
|
showDialog.value = true
|
|
|
|
nextTick(() => {
|
|
if (titleInput.value) {
|
|
titleInput.value.focus()
|
|
if (title.value) {
|
|
titleInput.value.select()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
function closeDialog() {
|
|
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
|
|
event.repeat = repeat.value
|
|
event.repeatInterval = recurrenceInterval.value
|
|
event.repeatWeekdays = buildStoreWeekdayPattern()
|
|
event.repeatCount =
|
|
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
|
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none'
|
|
}
|
|
}
|
|
|
|
function saveEvent() {
|
|
if (editingEventId.value) updateEventInStore()
|
|
eventSaved.value = true
|
|
if (dialogMode.value === 'create') {
|
|
unsavedCreateId.value = null
|
|
}
|
|
if (dialogMode.value === 'create') emit('clear-selection')
|
|
closeDialog()
|
|
}
|
|
|
|
function deleteEventAll() {
|
|
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
|
closeDialog()
|
|
}
|
|
|
|
function deleteEventOne() {
|
|
if (occurrenceContext.value) {
|
|
calendarStore.deleteSingleOccurrence(occurrenceContext.value)
|
|
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
|
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
|
}
|
|
closeDialog()
|
|
}
|
|
|
|
function deleteEventFrom() {
|
|
if (!occurrenceContext.value) return
|
|
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
|
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) {
|
|
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.isRepeating) return false
|
|
|
|
if (event.repeatCount === 'unlimited' || recurrenceOccurrences.value === 0) return false
|
|
|
|
const totalCount = parseInt(event.repeatCount, 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" @submit="saveEvent">
|
|
<template #title>
|
|
{{ dialogMode === 'create' ? 'Create Event' : 'Edit Event'
|
|
}}<template v-if="headerDateShort"> · {{ headerDateShort }}</template>
|
|
</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 }}
|
|
<template v-if="recurrenceOccurrences > 0">
|
|
until {{ formattedFinalOccurrence }}</template
|
|
>
|
|
</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"
|
|
/>
|
|
</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 {{ formattedOccurrenceShort }}
|
|
</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: 3em;
|
|
height: 1em;
|
|
}
|
|
|
|
.ec-color-swatches .swatch:checked {
|
|
outline-color: var(--ink);
|
|
}
|
|
|
|
.ec-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.75em;
|
|
}
|
|
|
|
.ec-btn {
|
|
border: 0.0625em solid var(--muted);
|
|
background: transparent;
|
|
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);
|
|
}
|
|
.mini-stepper {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
background: var(--panel-alt);
|
|
border: 1px solid var(--input-border);
|
|
border-radius: 0.5rem;
|
|
overflow: hidden;
|
|
font-size: 0.7rem;
|
|
height: 1.9rem;
|
|
}
|
|
.mini-stepper .step {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--ink);
|
|
padding: 0 0.55rem;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
line-height: 1;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
transition:
|
|
background-color 0.15s ease,
|
|
color 0.15s ease;
|
|
}
|
|
.mini-stepper .step:hover:not(:disabled) {
|
|
background: var(--pill-hover-bg);
|
|
}
|
|
.mini-stepper .step:disabled {
|
|
opacity: 0.35;
|
|
cursor: default;
|
|
}
|
|
.mini-stepper .value {
|
|
min-width: 1.6rem;
|
|
text-align: center;
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.mini-stepper:focus-within {
|
|
border-color: var(--input-focus);
|
|
box-shadow:
|
|
0 0 0 1px var(--input-focus),
|
|
0 0 0 4px rgba(37, 99, 235, 0.15);
|
|
}
|
|
.mini-stepper.occ .value {
|
|
min-width: 2rem;
|
|
}
|
|
.occ-stepper.mini-stepper.occ .value {
|
|
min-width: 2rem;
|
|
}
|
|
.mini-stepper .step:focus-visible {
|
|
outline: 2px solid var(--input-focus);
|
|
outline-offset: -2px;
|
|
}
|
|
.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: left;
|
|
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;
|
|
}
|
|
</style>
|