Add rich text editor and enhanced date picker to event dialogs
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:
Matt 2025-08-13 13:02:12 +02:00
parent 1553a39fa8
commit 5473555977
7 changed files with 1386 additions and 57 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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: {

1082
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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;
}
};

View File

@ -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)
})