df531908f3
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
280 lines
9.3 KiB
Vue
280 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import { parseCalendarDate } from '@/utils/date'
|
|
import { formatDisplayDate, formatDisplayTime, formatLocalIsoDate, formatLocalIsoTime } from '@/utils/format'
|
|
|
|
interface Props {
|
|
mode: 'edit' | 'view'
|
|
startsOn?: string | null
|
|
endsOn?: string | null
|
|
timeless?: boolean | null
|
|
timeZone?: string | null
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:startsOn': [date: string | null]
|
|
'update:endsOn': [date: string | null]
|
|
'update:timeless': [timeless: boolean | null]
|
|
'update:timeZone': [timeZone: string | null]
|
|
}>()
|
|
|
|
const timelessValue = computed({
|
|
get: () => props.timeless ?? false,
|
|
set: (value) => emit('update:timeless', value)
|
|
})
|
|
|
|
const timeZoneValue = computed({
|
|
get: () => props.timeZone ?? '',
|
|
set: (value) => emit('update:timeZone', value || null)
|
|
})
|
|
|
|
const formatDateTime = (isoString: string | null | undefined): string => {
|
|
if (!isoString) return 'Not set'
|
|
if (timelessValue.value) {
|
|
return parseCalendarDate(isoString)?.toLocaleDateString() ?? 'Not set'
|
|
}
|
|
const date = new Date(isoString)
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
const parseDateTime = (date: string, time: string): Date => {
|
|
if (timelessValue.value) {
|
|
return new Date(`${date}T00:00:00`)
|
|
}
|
|
return new Date(`${date}T${time}`)
|
|
}
|
|
|
|
const serializeDateTime = (date: string, time: string): string => {
|
|
return timelessValue.value
|
|
? `${date}T00:00:00.000Z`
|
|
: new Date(`${date}T${time}`).toISOString()
|
|
}
|
|
|
|
const inputDate = (value: string, timeless: boolean): string => {
|
|
return timeless ? value.slice(0, 10) : formatLocalIsoDate(new Date(value))
|
|
}
|
|
|
|
const pickerDate = (value: string): Date | null => value ? new Date(`${value}T00:00:00`) : null
|
|
|
|
const normalizePickerDate = (value: unknown): string => {
|
|
const selected = Array.isArray(value) ? value[0] : value
|
|
const date = selected instanceof Date ? selected : new Date(String(selected))
|
|
return Number.isNaN(date.getTime()) ? '' : formatLocalIsoDate(date)
|
|
}
|
|
|
|
// Date/time fields
|
|
const startDate = ref('')
|
|
const startTime = ref('')
|
|
const endDate = ref('')
|
|
const endTime = ref('')
|
|
const startDateMenu = ref(false)
|
|
const startTimeMenu = ref(false)
|
|
const endDateMenu = ref(false)
|
|
const endTimeMenu = ref(false)
|
|
|
|
const selectStartDate = (value: unknown) => {
|
|
const date = normalizePickerDate(value)
|
|
if (date) startDate.value = date
|
|
startDateMenu.value = false
|
|
}
|
|
|
|
const selectEndDate = (value: unknown) => {
|
|
const date = normalizePickerDate(value)
|
|
if (date) endDate.value = date
|
|
endDateMenu.value = false
|
|
}
|
|
|
|
// Initialize date/time from props
|
|
watch([() => props.startsOn, () => props.timeless], ([newValue, timeless]) => {
|
|
if (newValue) {
|
|
const start = new Date(newValue)
|
|
startDate.value = inputDate(newValue, timeless === true)
|
|
startTime.value = formatLocalIsoTime(start)
|
|
} else {
|
|
const now = new Date()
|
|
startDate.value = formatLocalIsoDate(now)
|
|
startTime.value = formatLocalIsoTime(now)
|
|
}
|
|
}, { immediate: true })
|
|
|
|
watch([() => props.endsOn, () => props.timeless], ([newValue, timeless]) => {
|
|
if (newValue) {
|
|
const end = new Date(newValue)
|
|
endDate.value = inputDate(newValue, timeless === true)
|
|
endTime.value = formatLocalIsoTime(end)
|
|
} else {
|
|
const later = new Date(Date.now() + 60 * 60 * 1000)
|
|
endDate.value = formatLocalIsoDate(later)
|
|
endTime.value = formatLocalIsoTime(later)
|
|
}
|
|
}, { immediate: true })
|
|
|
|
const ensureValidRange = (): boolean => {
|
|
if (!startDate.value || !endDate.value || !startTime.value || !endTime.value) return false
|
|
const start = parseDateTime(startDate.value, startTime.value)
|
|
const end = parseDateTime(endDate.value, endTime.value)
|
|
const valid = timelessValue.value
|
|
? end.getTime() > start.getTime()
|
|
: end.getTime() >= start.getTime()
|
|
if (valid) return false
|
|
|
|
const correctedEnd = new Date(start)
|
|
if (timelessValue.value) correctedEnd.setDate(correctedEnd.getDate() + 1)
|
|
else correctedEnd.setHours(correctedEnd.getHours() + 1)
|
|
endDate.value = formatLocalIsoDate(correctedEnd)
|
|
endTime.value = formatLocalIsoTime(correctedEnd)
|
|
return true
|
|
}
|
|
|
|
watch([startDate, startTime, endDate, endTime, timelessValue], () => {
|
|
if (props.mode !== 'edit' || ensureValidRange()) return
|
|
emit('update:startsOn', serializeDateTime(startDate.value, startTime.value))
|
|
emit('update:endsOn', serializeDateTime(endDate.value, endTime.value))
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="event-editor-section">
|
|
<div class="text-subtitle-1 mb-2">Date & Time</div>
|
|
|
|
<!-- Read-only view -->
|
|
<div v-if="mode === 'view'">
|
|
<div class="mb-2 pa-2">
|
|
<div class="mb-1"><strong>Starts:</strong> {{ formatDateTime(startsOn) }}</div>
|
|
<div class="mb-1"><strong>Ends:</strong> {{ formatDateTime(endsOn) }}</div>
|
|
<div v-if="timelessValue" class="mb-1">
|
|
<v-chip size="small" color="info" variant="tonal">
|
|
<v-icon start size="small">mdi-calendar-blank</v-icon>
|
|
All Day Event
|
|
</v-chip>
|
|
</div>
|
|
<div v-if="timeZoneValue" class="text-caption text-grey">Time Zone: {{ timeZoneValue }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit view -->
|
|
<div v-else>
|
|
<v-checkbox
|
|
v-model="timelessValue"
|
|
label="All day event"
|
|
density="compact"
|
|
class="mb-2"
|
|
/>
|
|
|
|
<div class="mb-3">
|
|
<div class="text-subtitle-2 mb-2">Start</div>
|
|
<v-row dense>
|
|
<v-col cols="12" md="6">
|
|
<v-menu v-model="startDateMenu" :close-on-content-click="false" location="bottom start">
|
|
<template #activator="{ props: activatorProps }">
|
|
<v-text-field
|
|
v-bind="activatorProps"
|
|
:model-value="formatDisplayDate(startDate)"
|
|
label="Start date"
|
|
prepend-inner-icon="mdi-calendar"
|
|
variant="outlined"
|
|
density="compact"
|
|
readonly
|
|
/>
|
|
</template>
|
|
<v-date-picker
|
|
:model-value="pickerDate(startDate)"
|
|
color="primary"
|
|
show-adjacent-months
|
|
@update:model-value="selectStartDate"
|
|
/>
|
|
</v-menu>
|
|
</v-col>
|
|
<v-col v-if="!timelessValue" cols="12" md="6">
|
|
<v-menu v-model="startTimeMenu" :close-on-content-click="false" location="bottom start">
|
|
<template #activator="{ props: activatorProps }">
|
|
<v-text-field
|
|
v-bind="activatorProps"
|
|
:model-value="formatDisplayTime(startTime)"
|
|
label="Start time"
|
|
prepend-inner-icon="mdi-clock-outline"
|
|
variant="outlined"
|
|
density="compact"
|
|
readonly
|
|
/>
|
|
</template>
|
|
<v-card>
|
|
<v-time-picker v-model="startTime" color="primary" format="ampm" />
|
|
<v-card-actions class="justify-end">
|
|
<v-btn variant="text" @click="startTimeMenu = false">Done</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-menu>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<div class="text-subtitle-2 mb-2">End</div>
|
|
<v-row dense>
|
|
<v-col cols="12" md="6">
|
|
<v-menu v-model="endDateMenu" :close-on-content-click="false" location="bottom start">
|
|
<template #activator="{ props: activatorProps }">
|
|
<v-text-field
|
|
v-bind="activatorProps"
|
|
:model-value="formatDisplayDate(endDate)"
|
|
label="End date"
|
|
prepend-inner-icon="mdi-calendar"
|
|
variant="outlined"
|
|
density="compact"
|
|
readonly
|
|
/>
|
|
</template>
|
|
<v-date-picker
|
|
:model-value="pickerDate(endDate)"
|
|
:min="pickerDate(startDate)"
|
|
color="primary"
|
|
show-adjacent-months
|
|
@update:model-value="selectEndDate"
|
|
/>
|
|
</v-menu>
|
|
</v-col>
|
|
<v-col v-if="!timelessValue" cols="12" md="6">
|
|
<v-menu v-model="endTimeMenu" :close-on-content-click="false" location="bottom start">
|
|
<template #activator="{ props: activatorProps }">
|
|
<v-text-field
|
|
v-bind="activatorProps"
|
|
:model-value="formatDisplayTime(endTime)"
|
|
label="End time"
|
|
prepend-inner-icon="mdi-clock-outline"
|
|
variant="outlined"
|
|
density="compact"
|
|
readonly
|
|
/>
|
|
</template>
|
|
<v-card>
|
|
<v-time-picker v-model="endTime" color="primary" format="ampm" />
|
|
<v-card-actions class="justify-end">
|
|
<v-btn variant="text" @click="endTimeMenu = false">Done</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-menu>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<v-text-field
|
|
v-model="timeZoneValue"
|
|
label="Time Zone"
|
|
variant="outlined"
|
|
density="compact"
|
|
hint="e.g., America/New_York"
|
|
persistent-hint
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.event-editor-section {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
</style>
|