Add rich text editor and enhanced date picker to event dialogs
Build And Push Image / docker (push) Failing after 3m11s
Details
Build And Push Image / docker (push) Failing after 3m11s
Details
Replace basic textarea with VuetifyTiptap rich text editor for event descriptions, supporting formatting options like bold, italic, headings, and lists. Replace native datetime inputs with VueDatePicker components featuring timezone support (Monaco/UTC) and improved UX. Update dependencies and add necessary plugins to support the new components.
This commit is contained in:
parent
1553a39fa8
commit
5473555977
|
|
@ -31,12 +31,27 @@
|
|||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
<VuetifyTiptap
|
||||
v-model="eventData.description"
|
||||
label="Description"
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
auto-grow
|
||||
:toolbar="[
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'|',
|
||||
'heading',
|
||||
'|',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'|',
|
||||
'link',
|
||||
'|',
|
||||
'undo',
|
||||
'redo'
|
||||
]"
|
||||
:max-height="200"
|
||||
placeholder="Enter event description with formatting..."
|
||||
outlined
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
|
|
@ -65,28 +80,46 @@
|
|||
|
||||
<!-- Date and Time -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="eventData.start_datetime"
|
||||
label="Start Date & Time*"
|
||||
type="datetime-local"
|
||||
:rules="[v => !!v || 'Start date is required']"
|
||||
variant="outlined"
|
||||
required
|
||||
/>
|
||||
<div class="date-picker-wrapper">
|
||||
<label class="date-picker-label">Start Date & Time*</label>
|
||||
<VueDatePicker
|
||||
v-model="startDateModel"
|
||||
:timezone="{
|
||||
timezone: 'Europe/Monaco',
|
||||
emitTimezone: 'UTC'
|
||||
}"
|
||||
:format="dateTimeFormat"
|
||||
placeholder="Select start date and time"
|
||||
:enable-time-picker="true"
|
||||
:is-24="true"
|
||||
auto-apply
|
||||
:clearable="false"
|
||||
:required="true"
|
||||
@update:model-value="handleStartDateUpdate"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="eventData.end_datetime"
|
||||
label="End Date & Time*"
|
||||
type="datetime-local"
|
||||
:rules="[
|
||||
v => !!v || 'End date is required',
|
||||
v => !eventData.start_datetime || new Date(v) > new Date(eventData.start_datetime) || 'End date must be after start date'
|
||||
]"
|
||||
variant="outlined"
|
||||
required
|
||||
/>
|
||||
<div class="date-picker-wrapper">
|
||||
<label class="date-picker-label">End Date & Time*</label>
|
||||
<VueDatePicker
|
||||
v-model="endDateModel"
|
||||
:timezone="{
|
||||
timezone: 'Europe/Monaco',
|
||||
emitTimezone: 'UTC'
|
||||
}"
|
||||
:format="dateTimeFormat"
|
||||
placeholder="Select end date and time"
|
||||
:enable-time-picker="true"
|
||||
:is-24="true"
|
||||
auto-apply
|
||||
:clearable="false"
|
||||
:required="true"
|
||||
:min-date="startDateModel"
|
||||
@update:model-value="handleEndDateUpdate"
|
||||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Location -->
|
||||
|
|
@ -259,6 +292,13 @@ const memberPricingEnabled = ref(true);
|
|||
const isRecurring = ref(false);
|
||||
const recurrenceFrequency = ref('weekly');
|
||||
|
||||
// Date picker state
|
||||
const startDateModel = ref<Date | null>(null);
|
||||
const endDateModel = ref<Date | null>(null);
|
||||
|
||||
// Date format for display
|
||||
const dateTimeFormat = 'dd/MM/yyyy HH:mm (Monaco)';
|
||||
|
||||
// Form data
|
||||
const eventData = reactive<EventCreateRequest>({
|
||||
title: '',
|
||||
|
|
@ -350,23 +390,43 @@ watch(recurrenceFrequency, (newValue) => {
|
|||
// Watch for prefilled dates
|
||||
watch(() => props.prefilledDate, (newDate) => {
|
||||
if (newDate) {
|
||||
eventData.start_datetime = newDate;
|
||||
const startDate = new Date(newDate);
|
||||
startDateModel.value = startDate;
|
||||
eventData.start_datetime = startDate.toISOString();
|
||||
|
||||
|
||||
// Set end date 2 hours later if not provided
|
||||
if (!props.prefilledEndDate) {
|
||||
const endDate = new Date(newDate);
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setHours(endDate.getHours() + 2);
|
||||
eventData.end_datetime = endDate.toISOString().slice(0, 16);
|
||||
endDateModel.value = endDate;
|
||||
eventData.end_datetime = endDate.toISOString();
|
||||
}
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.prefilledEndDate, (newEndDate) => {
|
||||
if (newEndDate) {
|
||||
eventData.end_datetime = newEndDate;
|
||||
const endDate = new Date(newEndDate);
|
||||
endDateModel.value = endDate;
|
||||
eventData.end_datetime = endDate.toISOString();
|
||||
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Date picker handlers
|
||||
const handleStartDateUpdate = (date: Date | null) => {
|
||||
if (date) {
|
||||
eventData.start_datetime = date.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateUpdate = (date: Date | null) => {
|
||||
if (date) {
|
||||
eventData.end_datetime = date.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
// Methods
|
||||
const resetForm = () => {
|
||||
eventData.title = '';
|
||||
|
|
@ -385,6 +445,10 @@ const resetForm = () => {
|
|||
eventData.is_recurring = 'false';
|
||||
eventData.recurrence_pattern = '';
|
||||
|
||||
// Reset date pickers
|
||||
startDateModel.value = null;
|
||||
endDateModel.value = null;
|
||||
|
||||
isPaidEvent.value = false;
|
||||
memberPricingEnabled.value = true;
|
||||
isRecurring.value = false;
|
||||
|
|
@ -420,35 +484,15 @@ const handleSubmit = async () => {
|
|||
const newEvent = await createEvent(formattedEventData);
|
||||
|
||||
emit('event-created', newEvent);
|
||||
|
||||
// Show success message
|
||||
// TODO: Add toast/snackbar notification
|
||||
console.log('Event created successfully:', newEvent);
|
||||
|
||||
close();
|
||||
} catch (error: any) {
|
||||
console.error('Error creating event:', error);
|
||||
// TODO: Add error toast/snackbar notification
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize form when dialog opens
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen && props.prefilledDate) {
|
||||
eventData.start_datetime = props.prefilledDate;
|
||||
|
||||
if (props.prefilledEndDate) {
|
||||
eventData.end_datetime = props.prefilledEndDate;
|
||||
} else {
|
||||
// Set end date 2 hours later
|
||||
const endDate = new Date(props.prefilledDate);
|
||||
endDate.setHours(endDate.getHours() + 2);
|
||||
eventData.end_datetime = endDate.toISOString().slice(0, 16);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Removed duplicate prefilled date logic - handled by watchers above
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -468,4 +512,73 @@ watch(show, (isOpen) => {
|
|||
.v-text-field :deep(.v-field__input) {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
/* Date picker styling to match Vuetify */
|
||||
.date-picker-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.date-picker-label {
|
||||
font-size: 16px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.009375em;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Style the Vue DatePicker to match Vuetify inputs */
|
||||
:deep(.dp__input) {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 4px;
|
||||
padding: 16px 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
transition: border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
:deep(.dp__input:hover) {
|
||||
border-color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
:deep(.dp__input:focus) {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
border-width: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:deep(.dp__input_readonly) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Style the date picker dropdown */
|
||||
:deep(.dp__menu) {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
/* Primary color theming for the date picker */
|
||||
:deep(.dp__primary_color) {
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
:deep(.dp__primary_text) {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
:deep(.dp__active_date) {
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
}
|
||||
|
||||
:deep(.dp__today) {
|
||||
border: 1px solid rgb(var(--v-theme-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,11 @@
|
|||
<v-icon class="me-2 mt-1">mdi-text</v-icon>
|
||||
<div>
|
||||
<div class="font-weight-medium mb-1">Description</div>
|
||||
<div class="text-body-2">{{ event.description }}</div>
|
||||
<!-- Display HTML content safely -->
|
||||
<div
|
||||
class="text-body-2 rich-text-content"
|
||||
v-html="event.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
|
@ -289,6 +293,7 @@ const show = computed({
|
|||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
|
||||
const userRSVP = computed((): EventRSVP | null => {
|
||||
return props.event?.user_rsvp || null;
|
||||
});
|
||||
|
|
@ -485,11 +490,8 @@ Reference: ${userRSVP.value?.payment_reference}
|
|||
|
||||
try {
|
||||
await navigator.clipboard.writeText(details);
|
||||
// TODO: Show success toast
|
||||
console.log('Payment details copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
// TODO: Show error toast
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -507,4 +509,65 @@ Reference: ${userRSVP.value?.payment_reference}
|
|||
.v-progress-linear {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* Rich text content styling */
|
||||
.rich-text-content {
|
||||
word-wrap: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(h1),
|
||||
.rich-text-content :deep(h2),
|
||||
.rich-text-content :deep(h3) {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(h1) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(h2) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(h3) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(ul),
|
||||
.rich-text-content :deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(li) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(u) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(a) {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rich-text-content :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export default defineNuxtConfig({
|
|||
}
|
||||
},
|
||||
modules: ["vuetify-nuxt-module",
|
||||
"vuetify-pro-tiptap/nuxt",
|
||||
// TEMPORARILY DISABLED FOR TESTING - PWA causing reload loops on mobile Safari
|
||||
// [
|
||||
// "@vite-pwa/nuxt",
|
||||
|
|
@ -68,6 +69,7 @@ export default defineNuxtConfig({
|
|||
// ],
|
||||
"@nuxtjs/device"],
|
||||
css: [
|
||||
'vuetify-pro-tiptap/style.css'
|
||||
],
|
||||
app: {
|
||||
head: {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -21,6 +21,7 @@
|
|||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@vite-pwa/nuxt": "^0.10.8",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"cookie": "^0.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"flag-icons": "^7.5.0",
|
||||
|
|
@ -37,7 +38,8 @@
|
|||
"vue": "latest",
|
||||
"vue-country-flag-next": "^2.3.2",
|
||||
"vue-router": "latest",
|
||||
"vuetify-nuxt-module": "^0.18.3"
|
||||
"vuetify-nuxt-module": "^0.18.3",
|
||||
"vuetify-pro-tiptap": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "^0.6.0",
|
||||
|
|
|
|||
|
|
@ -335,14 +335,77 @@ const clearFilters = async () => {
|
|||
};
|
||||
|
||||
const handleEventClick = (eventInfo: any) => {
|
||||
selectedEvent.value = (eventInfo.eventData || eventInfo.event || eventInfo) as Event;
|
||||
// Extract the original event data from FullCalendar's extendedProps
|
||||
const calendarEvent = eventInfo.event || eventInfo;
|
||||
const originalEvent = calendarEvent.extendedProps?.originalEvent;
|
||||
|
||||
// Use original event if available, otherwise reconstruct from calendar event
|
||||
if (originalEvent) {
|
||||
selectedEvent.value = originalEvent as Event;
|
||||
} else {
|
||||
// Fallback: reconstruct event from FullCalendar event data
|
||||
selectedEvent.value = {
|
||||
id: calendarEvent.id,
|
||||
title: calendarEvent.title,
|
||||
description: calendarEvent.extendedProps?.description || '',
|
||||
event_type: calendarEvent.extendedProps?.event_type || 'meeting',
|
||||
start_datetime: calendarEvent.start?.toISOString() || calendarEvent.startStr,
|
||||
end_datetime: calendarEvent.end?.toISOString() || calendarEvent.endStr,
|
||||
location: calendarEvent.extendedProps?.location || '',
|
||||
visibility: calendarEvent.extendedProps?.visibility || 'public',
|
||||
is_paid: calendarEvent.extendedProps?.is_paid ? 'true' : 'false',
|
||||
cost_members: calendarEvent.extendedProps?.cost_members || '',
|
||||
cost_non_members: calendarEvent.extendedProps?.cost_non_members || '',
|
||||
max_attendees: calendarEvent.extendedProps?.max_attendees?.toString() || '',
|
||||
current_attendees: calendarEvent.extendedProps?.current_attendees?.toString() || '0',
|
||||
user_rsvp: calendarEvent.extendedProps?.user_rsvp || null,
|
||||
creator: calendarEvent.extendedProps?.creator || '',
|
||||
status: 'active'
|
||||
} as Event;
|
||||
}
|
||||
|
||||
console.log('[Events] Selected event for dialog:', {
|
||||
id: selectedEvent.value.id,
|
||||
title: selectedEvent.value.title,
|
||||
event_type: selectedEvent.value.event_type
|
||||
});
|
||||
|
||||
showDetailsDialog.value = true;
|
||||
};
|
||||
|
||||
const handleDateClick = (dateInfo: any) => {
|
||||
if (isBoard.value || isAdmin.value) {
|
||||
prefilledDate.value = dateInfo.date;
|
||||
prefilledEndDate.value = dateInfo.endDate || '';
|
||||
// Debug: Log the date format being passed
|
||||
console.log('[Events] Date clicked:', dateInfo);
|
||||
|
||||
// Ensure proper ISO format for datetime-local inputs
|
||||
let formattedDate = '';
|
||||
let formattedEndDate = '';
|
||||
|
||||
if (dateInfo.date) {
|
||||
// Convert to local datetime-local format: YYYY-MM-DDTHH:mm
|
||||
const clickedDate = new Date(dateInfo.date);
|
||||
clickedDate.setHours(18, 0, 0, 0); // Default to 6 PM
|
||||
formattedDate = clickedDate.toISOString().slice(0, 16);
|
||||
|
||||
// Set end date 2 hours later if not provided
|
||||
if (dateInfo.endDate) {
|
||||
formattedEndDate = new Date(dateInfo.endDate).toISOString().slice(0, 16);
|
||||
} else {
|
||||
const endDate = new Date(clickedDate);
|
||||
endDate.setHours(20, 0, 0, 0); // Default to 8 PM
|
||||
formattedEndDate = endDate.toISOString().slice(0, 16);
|
||||
}
|
||||
}
|
||||
|
||||
prefilledDate.value = formattedDate;
|
||||
prefilledEndDate.value = formattedEndDate;
|
||||
|
||||
console.log('[Events] Prefilled dates:', {
|
||||
date: formattedDate,
|
||||
endDate: formattedEndDate
|
||||
});
|
||||
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import VueDatePicker from '@vuepic/vue-datepicker'
|
||||
import '@vuepic/vue-datepicker/dist/main.css'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('VueDatePicker', VueDatePicker)
|
||||
})
|
||||
Loading…
Reference in New Issue