Compare commits

..

9 Commits

Author SHA1 Message Date
Ryderjj89
6e682188bb More display fixes 2025-11-25 08:44:33 -05:00
Ryderjj89
021edda0dc Fixed description display again 2025-11-25 08:35:48 -05:00
Ryderjj89
453d4eb5c6 Fixed description display 2025-11-25 08:28:35 -05:00
Ryderjj89
0f631c8755 Newer react quill 2025-11-25 08:12:32 -05:00
Ryderjj89
e6002d6865 Merge branch 'main' of https://git.rydertech.us/ryder/rsvp-manager 2025-11-25 08:08:20 -05:00
Ryderjj89
757209310f Updated description box and fixed title renaming 2025-11-25 08:07:51 -05:00
049c203f25 Update image url 2025-11-09 18:47:01 -05:00
Ryderjj89
0991e17670 updated git url in readme 2025-11-09 18:10:32 -05:00
Ryderjj89
f241b4a2e7 Updated image url 2025-11-09 17:47:08 -05:00
11 changed files with 359 additions and 82 deletions

View File

@@ -61,15 +61,6 @@ This project was created completely by the [Cursor AI Code Editor](https://www.c
### Installation
#### Branch Selection
There are 2 branches, latest & dev.
| Branch | Description |
| ------------- | ------------- |
| Latest | The most recent stable build. Use this if you don't like to get changes early. |
| Dev | Use this if you want to be on the cutting edge. This can be unstable or even broken. |
#### Environment Variables
These variables below are all for the email notifications. If you want to be able to send email notifications correctly, each of these needs to be provided and filled out.
@@ -113,7 +104,7 @@ docker run -d --name rsvp-manager \
-e FRONTEND_BASE_URL=https://rsvp.example.com \
-e TZ=<CHANGE THIS!>
--restart unless-stopped \
ryderjj89/rsvp-manager:<CHANGE THIS TAG!>
git.rydertech.us/ryder/rsvp-manager:latest
```
2. Access the application at `http://localhost:3000`

View File

@@ -835,6 +835,19 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
return res.status(404).json({ error: 'Event not found' });
}
// Generate new slug if title changed
let newSlug = slug;
if (title && title !== eventRows[0].title) {
newSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
// Check if new slug already exists (and it's not the current event)
const existingEvent = await db.get('SELECT id FROM events WHERE slug = ? AND id != ?', [newSlug, eventRows[0].id]);
if (existingEvent) {
// Append a timestamp to make it unique
newSlug = `${newSlug}-${Date.now()}`;
}
}
// Ensure needed_items is properly formatted
let parsedNeededItems: string[] = [];
try {
@@ -886,9 +899,9 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
wallpaperPath = req.file.filename;
}
// Update the event
// Update the event (including the slug if it changed)
await db.run(
'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ?, email_notifications_enabled = ?, email_recipients = ?, event_conclusion_email_enabled = ?, event_conclusion_message = ? WHERE slug = ?',
'UPDATE events SET title = ?, description = ?, date = ?, location = ?, needed_items = ?, rsvp_cutoff_date = ?, wallpaper = ?, max_guests_per_rsvp = ?, email_notifications_enabled = ?, email_recipients = ?, event_conclusion_email_enabled = ?, event_conclusion_message = ?, slug = ? WHERE slug = ?',
[
title ?? eventRows[0].title,
description === undefined ? eventRows[0].description : description,
@@ -902,12 +915,13 @@ app.put('/api/events/:slug', upload.single('wallpaper'), async (req: MulterReque
emailRecipients,
eventConclusionEmailEnabled ? 1 : 0, // Update new field
eventConclusionMessage, // Update new field
slug
newSlug, // Update slug if it changed
slug // WHERE clause uses the old slug
]
);
// Get the updated event
const updatedEvent = await db.get('SELECT * FROM events WHERE slug = ?', [slug]);
// Get the updated event using the new slug
const updatedEvent = await db.get('SELECT * FROM events WHERE slug = ?', [newSlug]);
// Add the full path to the wallpaper and parse JSON/boolean fields
if (updatedEvent.wallpaper) {

View File

@@ -1,7 +1,7 @@
services:
app:
container_name: rsvp_manager
image: ryderjj89/rsvp-manager:latest
image: git.rydertech.us/rydertech/rsvp-manager:latest
ports:
- "3000:3000"
volumes:

View File

@@ -16,7 +16,8 @@
"@mui/material": "^5.14.2",
"@mui/icons-material": "^5.14.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0"
"@emotion/styled": "^11.11.0",
"react-quill-new": "^3.3.3"
},
"devDependencies": {
"@types/react": "^18.2.15",

View File

@@ -39,6 +39,7 @@ import WallpaperIcon from '@mui/icons-material/Wallpaper';
import EmailIcon from '@mui/icons-material/Email';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import axios from 'axios';
import RichTextEditor from './RichTextEditor';
interface RSVP {
id: number;
@@ -621,11 +622,11 @@ const EventAdmin: React.FC = () => {
try {
// Create FormData and append all fields
const formData = new FormData();
formData.append('title', updateForm.title); // Submit the updated title
formData.append('description', updateForm.description);
formData.append('location', updateForm.location);
formData.append('date', updateForm.date);
formData.append('rsvp_cutoff_date', updateForm.rsvp_cutoff_date);
formData.append('title', event.title); // Keep existing title
formData.append('needed_items', JSON.stringify(event.needed_items)); // Keep existing needed items
formData.append('email_notifications_enabled', updateForm.email_notifications_enabled.toString());
formData.append('email_recipients', updateForm.email_recipients);
@@ -644,19 +645,33 @@ const EventAdmin: React.FC = () => {
'Content-Type': 'multipart/form-data',
},
});
setEvent(prev => prev ? {
...prev,
description: updateForm.description,
location: updateForm.location,
date: updateForm.date,
rsvp_cutoff_date: updateForm.rsvp_cutoff_date,
wallpaper: response.data.wallpaper || prev.wallpaper,
email_notifications_enabled: updateForm.email_notifications_enabled,
email_recipients: updateForm.email_recipients
} : null);
setUpdateInfoDialogOpen(false);
// Check if slug changed (title was updated)
const newSlug = response.data.slug;
if (newSlug && newSlug !== slug) {
// Slug changed - redirect to new URL
setUpdateInfoDialogOpen(false);
navigate(`/admin/events/${newSlug}`, { replace: true });
// Refresh the page to load the new event data
window.location.href = `/admin/events/${newSlug}`;
} else {
// Slug didn't change - just update local state
setEvent(prev => prev ? {
...prev,
title: updateForm.title,
description: updateForm.description,
location: updateForm.location,
date: updateForm.date,
rsvp_cutoff_date: updateForm.rsvp_cutoff_date,
wallpaper: response.data.wallpaper || prev.wallpaper,
email_notifications_enabled: updateForm.email_notifications_enabled,
email_recipients: updateForm.email_recipients,
event_conclusion_email_enabled: updateForm.event_conclusion_email_enabled,
event_conclusion_message: updateForm.event_conclusion_message
} : null);
setUpdateInfoDialogOpen(false);
}
} catch (error) {
console.error('Error updating event:', error);
setError('Failed to update event information');
@@ -824,8 +839,21 @@ const EventAdmin: React.FC = () => {
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" gutterBottom>
<strong>Info:</strong> {event.description || 'None'}
<strong>Info:</strong>
</Typography>
<Box
sx={{
mb: 2,
pl: 2,
'& p': { margin: 0, lineHeight: 1.5 },
'& br': { display: 'block', content: '""', marginTop: '0.5em' },
'& ul, & ol': { marginLeft: 2, marginTop: '0.5em', marginBottom: '0.5em', paddingLeft: '1.5em' },
'& li': { marginBottom: 0, lineHeight: 1.5 },
'& h1, & h2, & h3': { marginTop: '0.75em', marginBottom: '0.5em' },
'& a': { color: 'primary.main' },
}}
dangerouslySetInnerHTML={{ __html: event.description || 'None' }}
/>
<Typography variant="subtitle1" gutterBottom>
<strong>Date:</strong> {new Date(event.date).toLocaleString()}
</Typography>
@@ -1233,13 +1261,12 @@ const EventAdmin: React.FC = () => {
onChange={(e) => setUpdateForm(prev => ({ ...prev, title: e.target.value }))}
fullWidth
/>
<TextField
label="Description"
<RichTextEditor
value={updateForm.description}
onChange={(e) => setUpdateForm(prev => ({ ...prev, description: e.target.value }))}
fullWidth
multiline
rows={3}
onChange={(value) => setUpdateForm(prev => ({ ...prev, description: value }))}
label="Description"
placeholder="Enter event description with formatting..."
theme="light"
/>
<TextField
label="Location"
@@ -1320,20 +1347,19 @@ const EventAdmin: React.FC = () => {
/>
{updateForm.event_conclusion_email_enabled && (
<TextField
fullWidth
label="Event conclusion message"
value={updateForm.event_conclusion_message}
onChange={(e) => setUpdateForm(prev => ({
...prev,
event_conclusion_message: e.target.value
}))}
variant="outlined"
multiline
rows={4}
helperText="This message will be sent to attendees who opted for email notifications the day after the event."
sx={{ mt: 2 }}
/>
<Box sx={{ mt: 2 }}>
<RichTextEditor
value={updateForm.event_conclusion_message}
onChange={(value) => setUpdateForm(prev => ({
...prev,
event_conclusion_message: value
}))}
label="Event conclusion message"
placeholder="Enter the message to send after the event..."
theme="light"
helperText="This message will be sent to attendees who opted for email notifications the day after the event."
/>
</Box>
)}
</Box>

View File

@@ -107,9 +107,17 @@ const EventDetails: React.FC = () => {
<Typography variant="h4" component="h1" gutterBottom>
{event.title}
</Typography>
<Typography variant="body1" paragraph>
{event.description}
</Typography>
<Box
sx={{
'& p': { margin: 0, lineHeight: 1.5 },
'& br': { display: 'block', content: '""', marginTop: '0.5em' },
'& ul, & ol': { marginLeft: 2, marginTop: '0.5em', marginBottom: '0.5em', paddingLeft: '1.5em' },
'& li': { marginBottom: 0, lineHeight: 1.5 },
'& h1, & h2, & h3': { marginTop: '0.75em', marginBottom: '0.5em' },
'& a': { color: 'primary.main' },
}}
dangerouslySetInnerHTML={{ __html: event.description }}
/>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" component="div" gutterBottom>
<strong>Date:</strong> {new Date(event.date).toLocaleDateString()}

View File

@@ -18,6 +18,7 @@ import WallpaperIcon from '@mui/icons-material/Wallpaper';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import axios from 'axios';
import RichTextEditor from './RichTextEditor';
const DarkTextField = styled(TextField)({
'& .MuiOutlinedInput-root': {
@@ -185,15 +186,12 @@ const EventForm: React.FC = () => {
variant="outlined"
required
/>
<DarkTextField
fullWidth
label="Description"
name="description"
<RichTextEditor
value={formData.description}
onChange={handleChange}
variant="outlined"
multiline
rows={4}
onChange={(value) => setFormData((prev) => ({ ...prev, description: value }))}
label="Description"
placeholder="Enter event description with formatting..."
theme="dark"
/>
<DarkTextField
fullWidth
@@ -435,18 +433,16 @@ const EventForm: React.FC = () => {
/>
{formData.event_conclusion_email_enabled && (
<DarkTextField
fullWidth
label="Event conclusion message"
name="event_conclusion_message" // Corrected name prop
value={formData.event_conclusion_message} // Corrected value prop
onChange={handleChange}
variant="outlined"
multiline
rows={4}
helperText="This message will be sent to attendees who opted for email notifications the day after the event."
sx={{ mt: 2 }} // Added margin top for spacing
/>
<Box sx={{ mt: 2 }}>
<RichTextEditor
value={formData.event_conclusion_message}
onChange={(value) => setFormData((prev) => ({ ...prev, event_conclusion_message: value }))}
label="Event conclusion message"
placeholder="Enter the message to send after the event..."
theme="dark"
helperText="This message will be sent to attendees who opted for email notifications the day after the event."
/>
</Box>
)}
</Box>
</Box>

View File

@@ -153,9 +153,26 @@ const EventList: React.FC = () => {
/>
</Box>
{event.description && (
<Typography variant="body2" color="text.secondary">
<strong>Info:</strong> {event.description}
</Typography>
<>
<Typography variant="body2" color="text.secondary" gutterBottom>
<strong>Info:</strong>
</Typography>
<Box
sx={{
pl: 2,
mb: 1,
color: 'text.secondary',
fontSize: '0.875rem',
'& p': { margin: 0, lineHeight: 1.5 },
'& br': { display: 'block', content: '""', marginTop: '0.5em' },
'& ul, & ol': { marginLeft: 2, marginTop: '0.5em', marginBottom: '0.5em', paddingLeft: '1.5em' },
'& li': { marginBottom: 0, lineHeight: 1.5 },
'& h1, & h2, & h3': { marginTop: '0.75em', marginBottom: '0.25em', fontSize: '1rem' },
'& a': { color: 'primary.main' },
}}
dangerouslySetInnerHTML={{ __html: event.description }}
/>
</>
)}
<Typography variant="body2" color="text.secondary">
<strong>Date:</strong> {new Date(event.date).toLocaleString()}

View File

@@ -187,8 +187,21 @@ const EventView: React.FC = () => {
<Box sx={{ mb: 4 }}>
<Typography variant="subtitle1" gutterBottom>
<strong>Info:</strong> {event.description || 'None'}
<strong>Info:</strong>
</Typography>
<Box
sx={{
mb: 2,
pl: 2,
'& p': { margin: 0, lineHeight: 1.5 },
'& br': { display: 'block', content: '""', marginTop: '0.5em' },
'& ul, & ol': { marginLeft: 2, marginTop: '0.5em', marginBottom: '0.5em', paddingLeft: '1.5em' },
'& li': { marginBottom: 0, lineHeight: 1.5 },
'& h1, & h2, & h3': { marginTop: '0.75em', marginBottom: '0.5em' },
'& a': { color: 'primary.main' },
}}
dangerouslySetInnerHTML={{ __html: event.description || 'None' }}
/>
<Typography variant="subtitle1" gutterBottom>
<strong>Date:</strong> {new Date(event.date).toLocaleString()}
</Typography>

View File

@@ -453,9 +453,25 @@ const RSVPForm: React.FC = () => {
{event.title}
</Typography>
{event.description && (
<Typography variant="body2" color="text.secondary">
<strong>Info:</strong> {event.description}
</Typography>
<>
<Typography variant="body2" color="text.secondary" gutterBottom>
<strong>Info:</strong>
</Typography>
<Box
sx={{
pl: 2,
color: 'text.secondary',
fontSize: '0.875rem',
'& p': { margin: 0, lineHeight: 1.5 },
'& br': { display: 'block', content: '""', marginTop: '0.5em' },
'& ul, & ol': { marginLeft: 2, marginTop: '0.5em', marginBottom: '0.5em', paddingLeft: '1.5em' },
'& li': { marginBottom: 0, lineHeight: 1.5 },
'& h1, & h2, & h3': { marginTop: '0.75em', marginBottom: '0.5em' },
'& a': { color: 'primary.main' },
}}
dangerouslySetInnerHTML={{ __html: event.description }}
/>
</>
)}
{event.description && <Divider sx={{ my: 2 }} />} {/* Separator after Info */}
<Box> {/* Grouping Date, Location, and Note */}

View File

@@ -0,0 +1,195 @@
import React from 'react';
import ReactQuill from 'react-quill-new';
import 'react-quill-new/dist/quill.snow.css';
import { Box, Typography, styled } from '@mui/material';
const StyledEditorContainer = styled(Box)(({ theme }) => ({
'& .quill': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: theme.shape.borderRadius,
border: '1px solid rgba(255, 255, 255, 0.23)',
'&:hover': {
borderColor: 'rgba(255, 255, 255, 0.4)',
},
'&:focus-within': {
borderColor: theme.palette.primary.main,
borderWidth: '2px',
},
},
'& .ql-toolbar': {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderTopLeftRadius: theme.shape.borderRadius,
borderTopRightRadius: theme.shape.borderRadius,
border: 'none',
borderBottom: '1px solid rgba(255, 255, 255, 0.23)',
},
'& .ql-container': {
borderBottomLeftRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
border: 'none',
fontFamily: theme.typography.fontFamily,
fontSize: '1rem',
minHeight: '150px',
},
'& .ql-editor': {
minHeight: '150px',
color: 'rgba(255, 255, 255, 0.9)',
'&.ql-blank::before': {
color: 'rgba(255, 255, 255, 0.5)',
fontStyle: 'normal',
},
},
'& .ql-stroke': {
stroke: 'rgba(255, 255, 255, 0.7) !important',
},
'& .ql-fill': {
fill: 'rgba(255, 255, 255, 0.7) !important',
},
'& .ql-picker-label': {
color: 'rgba(255, 255, 255, 0.7) !important',
},
'& .ql-picker-options': {
backgroundColor: 'rgb(30, 30, 30) !important',
border: '1px solid rgba(255, 255, 255, 0.23) !important',
},
'& .ql-picker-item': {
color: 'rgba(255, 255, 255, 0.9) !important',
'&:hover': {
color: '#90caf9 !important',
},
},
'& .ql-toolbar button:hover .ql-stroke': {
stroke: '#90caf9 !important',
},
'& .ql-toolbar button:hover .ql-fill': {
fill: '#90caf9 !important',
},
'& .ql-toolbar button.ql-active .ql-stroke': {
stroke: '#90caf9 !important',
},
'& .ql-toolbar button.ql-active .ql-fill': {
fill: '#90caf9 !important',
},
}));
const LightEditorContainer = styled(Box)(({ theme }) => ({
'& .quill': {
backgroundColor: '#fff',
borderRadius: theme.shape.borderRadius,
border: '1px solid rgba(0, 0, 0, 0.23)',
'&:hover': {
borderColor: 'rgba(0, 0, 0, 0.87)',
},
'&:focus-within': {
borderColor: theme.palette.primary.main,
borderWidth: '2px',
},
},
'& .ql-toolbar': {
backgroundColor: 'rgba(0, 0, 0, 0.02)',
borderTopLeftRadius: theme.shape.borderRadius,
borderTopRightRadius: theme.shape.borderRadius,
border: 'none',
borderBottom: '1px solid rgba(0, 0, 0, 0.12)',
},
'& .ql-container': {
borderBottomLeftRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
border: 'none',
fontFamily: theme.typography.fontFamily,
fontSize: '1rem',
minHeight: '150px',
},
'& .ql-editor': {
minHeight: '150px',
color: 'rgba(0, 0, 0, 0.87)',
'&.ql-blank::before': {
color: 'rgba(0, 0, 0, 0.5)',
fontStyle: 'normal',
},
},
}));
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
theme?: 'dark' | 'light';
helperText?: string;
minHeight?: number;
}
const RichTextEditor: React.FC<RichTextEditorProps> = ({
value,
onChange,
label,
placeholder,
theme = 'dark',
helperText,
minHeight = 150,
}) => {
const modules = {
toolbar: [
[{ 'header': [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'indent': '-1' }, { 'indent': '+1' }],
['link'],
['clean'],
],
};
const formats = [
'header',
'bold', 'italic', 'underline', 'strike',
'list', 'bullet',
'indent',
'link',
];
const Container = theme === 'dark' ? StyledEditorContainer : LightEditorContainer;
return (
<Box sx={{ width: '100%' }}>
{label && (
<Typography
variant="subtitle2"
sx={{
mb: 1,
color: theme === 'dark' ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.6)',
fontSize: '0.875rem',
}}
>
{label}
</Typography>
)}
<Container>
<ReactQuill
value={value}
onChange={onChange}
modules={modules}
formats={formats}
placeholder={placeholder}
theme="snow"
style={{ minHeight: `${minHeight}px` }}
/>
</Container>
{helperText && (
<Typography
variant="caption"
sx={{
mt: 0.5,
ml: 1.5,
display: 'block',
color: theme === 'dark' ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)',
}}
>
{helperText}
</Typography>
)}
</Box>
);
};
export default RichTextEditor;