Compare commits
36 Commits
7b3f8f74a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e682188bb | ||
|
|
021edda0dc | ||
|
|
453d4eb5c6 | ||
|
|
0f631c8755 | ||
|
|
e6002d6865 | ||
|
|
757209310f | ||
| 049c203f25 | |||
|
|
0991e17670 | ||
|
|
f241b4a2e7 | ||
|
|
53340fc210 | ||
|
|
bd8f0fa2cd | ||
|
|
ecd53fbadb | ||
|
|
630cf7be41 | ||
|
|
9760eccf4c | ||
|
|
54d0e8aab4 | ||
|
|
5d67d42205 | ||
|
|
becc530c18 | ||
|
|
2587a5663b | ||
|
|
3af12b431b | ||
|
|
f366a9e4f3 | ||
|
|
044b7f2cde | ||
|
|
88c06e06f6 | ||
|
|
e0a3f3f889 | ||
|
|
aa69252657 | ||
|
|
01939f5881 | ||
|
|
2b6f4cfd2f | ||
|
|
16db01292c | ||
|
|
52cacc8646 | ||
|
|
4546185a89 | ||
|
|
4722aeeb22 | ||
|
|
4e66ce876d | ||
|
|
482718050d | ||
|
|
b5ecb32893 | ||
|
|
4d4920a751 | ||
|
|
3521643196 | ||
|
|
64db6c4c08 |
11
README.md
11
README.md
@@ -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`
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
@@ -729,7 +744,7 @@ const EventAdmin: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Container maxWidth="xl">
|
||||
<Typography>Loading...</Typography>
|
||||
</Container>
|
||||
);
|
||||
@@ -737,7 +752,7 @@ const EventAdmin: React.FC = () => {
|
||||
|
||||
if (error || !event) {
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Container maxWidth="xl">
|
||||
<Typography color="error">{error || 'Event not found'}</Typography>
|
||||
</Container>
|
||||
);
|
||||
@@ -763,7 +778,7 @@ const EventAdmin: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Container maxWidth="xl">
|
||||
<Paper elevation={3} sx={{ p: { xs: 2, sm: 4 }, mt: 4 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" component="h2" color="primary" gutterBottom>
|
||||
@@ -824,18 +839,31 @@ 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>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
<strong>Location:</strong> {event.location}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
<strong>Date:</strong> {new Date(event.date).toLocaleString()}
|
||||
</Typography>
|
||||
{event.rsvp_cutoff_date && (
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
<strong>RSVP cut-off date:</strong> {new Date(event.rsvp_cutoff_date).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
<strong>RSVP cut-off date:</strong> {new Date(event.rsvp_cutoff_date).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -945,9 +973,9 @@ const EventAdmin: React.FC = () => {
|
||||
<TableCell>
|
||||
{rsvp.bringing_guests === 'yes' ?
|
||||
`${rsvp.guest_count || 0} (${Array.isArray(rsvp.guest_names) ?
|
||||
rsvp.guest_names.join(', ') :
|
||||
rsvp.guest_names.map(name => name.trim()).join(', ') :
|
||||
typeof rsvp.guest_names === 'string' ?
|
||||
rsvp.guest_names.replace(/\s+/g, ', ') :
|
||||
rsvp.guest_names.replace(/\s+/g, ', ').trim() :
|
||||
'No names provided'})` :
|
||||
'No'
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -126,7 +126,7 @@ const EventView: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Container maxWidth="xl">
|
||||
<Typography>Loading...</Typography>
|
||||
</Container>
|
||||
);
|
||||
@@ -134,7 +134,7 @@ const EventView: React.FC = () => {
|
||||
|
||||
if (error || !event) {
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Container maxWidth="xl">
|
||||
<Typography color="error">{error || 'Event not found'}</Typography>
|
||||
</Container>
|
||||
);
|
||||
@@ -160,7 +160,7 @@ const EventView: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Container maxWidth="lg">
|
||||
<Container maxWidth="xl">
|
||||
<Paper elevation={3} sx={{ p: { xs: 2, sm: 4 }, mt: 4 }}>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" component="h2" color="primary" gutterBottom>
|
||||
@@ -187,18 +187,31 @@ 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>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
<strong>Location:</strong> {event.location}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
<strong>Date:</strong> {new Date(event.date).toLocaleString()}
|
||||
</Typography>
|
||||
{event.rsvp_cutoff_date && (
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
<strong>RSVP cut-off date:</strong> {new Date(event.rsvp_cutoff_date).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
<strong>RSVP cut-off date:</strong> {new Date(event.rsvp_cutoff_date).toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -293,9 +306,9 @@ const EventView: React.FC = () => {
|
||||
<TableCell>
|
||||
{rsvp.bringing_guests === 'yes' ?
|
||||
`${rsvp.guest_count} (${Array.isArray(rsvp.guest_names) ?
|
||||
rsvp.guest_names.join(', ') :
|
||||
rsvp.guest_names.map(name => name.trim()).join(', ') :
|
||||
typeof rsvp.guest_names === 'string' ?
|
||||
rsvp.guest_names.replace(/\s+/g, ', ') :
|
||||
rsvp.guest_names.replace(/\s+/g, ', ').trim() :
|
||||
'No names provided'})` :
|
||||
'No'
|
||||
}
|
||||
@@ -350,4 +363,4 @@ const EventView: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default EventView;
|
||||
export default EventView;
|
||||
|
||||
@@ -399,19 +399,19 @@ const RSVPForm: React.FC = () => {
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Back to Events
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate('/')}
|
||||
onClick={() => navigate(`/view/events/${slug}`)}
|
||||
sx={{ flexGrow: 1 }}
|
||||
>
|
||||
Back to Events
|
||||
View RSVPs
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -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 */}
|
||||
@@ -538,51 +554,69 @@ const RSVPForm: React.FC = () => {
|
||||
|
||||
{formData.bringing_guests === 'yes' && (
|
||||
<>
|
||||
<TextField
|
||||
label="Number of Guests"
|
||||
name="guest_count"
|
||||
type="number"
|
||||
value={formData.guest_count}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (isNaN(value)) return;
|
||||
|
||||
// Check if there's a maximum guest limit
|
||||
const maxGuests = event?.max_guests_per_rsvp;
|
||||
let newCount = value;
|
||||
|
||||
// If max_guests_per_rsvp is set and not -1 (unlimited), enforce the limit
|
||||
if (maxGuests !== undefined && maxGuests !== -1 && value > maxGuests) {
|
||||
newCount = maxGuests;
|
||||
}
|
||||
|
||||
// Ensure count is at least 1
|
||||
if (newCount < 1) newCount = 1;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
guest_count: newCount,
|
||||
guest_names: Array(newCount).fill('').map((_, i) => prev.guest_names[i] || '')
|
||||
}));
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
required
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: event?.max_guests_per_rsvp === -1 ? undefined : event?.max_guests_per_rsvp
|
||||
}}
|
||||
error={formData.guest_count < 1}
|
||||
helperText={
|
||||
formData.guest_count < 1
|
||||
? "Number of guests must be at least 1"
|
||||
: event?.max_guests_per_rsvp === 0
|
||||
? "No additional guests allowed for this event"
|
||||
: event?.max_guests_per_rsvp === -1
|
||||
? "No limit on number of guests"
|
||||
: `Maximum ${event?.max_guests_per_rsvp} additional guests allowed`
|
||||
}
|
||||
/>
|
||||
{/* Render dropdown if there's a max limit, otherwise render number input */}
|
||||
{event?.max_guests_per_rsvp !== undefined && event?.max_guests_per_rsvp !== -1 ? (
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Number of Guests</InputLabel>
|
||||
<Select
|
||||
name="guest_count"
|
||||
value={formData.guest_count.toString()}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
guest_count: value,
|
||||
guest_names: Array(value).fill('').map((_, i) => prev.guest_names[i] || '')
|
||||
}));
|
||||
}}
|
||||
label="Number of Guests"
|
||||
required
|
||||
>
|
||||
{Array.from({ length: event.max_guests_per_rsvp }, (_, i) => i + 1).map((num) => (
|
||||
<MenuItem key={num} value={num.toString()}>
|
||||
{num}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, ml: 1.75 }}>
|
||||
Maximum {event.max_guests_per_rsvp} additional guests allowed
|
||||
</Typography>
|
||||
</FormControl>
|
||||
) : (
|
||||
<TextField
|
||||
label="Number of Guests"
|
||||
name="guest_count"
|
||||
value={formData.guest_count}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
// Only allow numbers
|
||||
if (!/^\d*$/.test(value)) return;
|
||||
|
||||
const numValue = parseInt(value) || 0;
|
||||
// Ensure count is at least 1 when not empty
|
||||
const newCount = value === '' ? 0 : Math.max(1, numValue);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
guest_count: newCount,
|
||||
guest_names: Array(newCount).fill('').map((_, i) => prev.guest_names[i] || '')
|
||||
}));
|
||||
}}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
required
|
||||
inputProps={{
|
||||
inputMode: 'numeric',
|
||||
pattern: '[0-9]*'
|
||||
}}
|
||||
error={formData.guest_count < 1}
|
||||
helperText={
|
||||
formData.guest_count < 1
|
||||
? "Number of guests must be at least 1"
|
||||
: "No limit on number of guests"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Array.from({ length: formData.guest_count }).map((_, index) => (
|
||||
<TextField
|
||||
@@ -599,65 +633,83 @@ const RSVPForm: React.FC = () => {
|
||||
)}
|
||||
|
||||
{neededItems.length > 0 && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>What items are you bringing?</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
name="items_bringing"
|
||||
value={formData.items_bringing}
|
||||
onChange={handleItemsChange}
|
||||
input={<OutlinedInput label="What items are you bringing?" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} />
|
||||
<>
|
||||
{claimedItems.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 500 }}>
|
||||
Items already being brought:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{claimedItems.map((item) => (
|
||||
<Chip
|
||||
key={item}
|
||||
label={item}
|
||||
color="success"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
open={isItemsSelectOpen} // Control open state
|
||||
onOpen={() => setIsItemsSelectOpen(true)} // Set open when opened
|
||||
onClose={() => setIsItemsSelectOpen(false)} // Set closed when closed
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 300, // Limit height of the dropdown
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
MenuListProps: {
|
||||
sx: {
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{neededItems.map((item) => (
|
||||
<MenuItem key={item} value={item}>
|
||||
<Checkbox checked={formData.items_bringing.includes(item)} />
|
||||
<ListItemText primary={item} />
|
||||
</MenuItem>
|
||||
))}
|
||||
<Box sx={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'background.paper',
|
||||
padding: 1,
|
||||
zIndex: 1,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setIsItemsSelectOpen(false)}
|
||||
fullWidth
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Box>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>What items are you bringing?</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
name="items_bringing"
|
||||
value={formData.items_bringing}
|
||||
onChange={handleItemsChange}
|
||||
input={<OutlinedInput label="What items are you bringing?" />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
open={isItemsSelectOpen} // Control open state
|
||||
onOpen={() => setIsItemsSelectOpen(true)} // Set open when opened
|
||||
onClose={() => setIsItemsSelectOpen(false)} // Set closed when closed
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
maxHeight: 300, // Limit height of the dropdown
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
MenuListProps: {
|
||||
sx: {
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{neededItems.map((item) => (
|
||||
<MenuItem key={item} value={item}>
|
||||
<Checkbox checked={formData.items_bringing.includes(item)} />
|
||||
<ListItemText primary={item} />
|
||||
</MenuItem>
|
||||
))}
|
||||
<Box sx={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'background.paper',
|
||||
padding: 1,
|
||||
zIndex: 1,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'divider',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setIsItemsSelectOpen(false)}
|
||||
fullWidth
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Box>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
|
||||
195
frontend/src/components/RichTextEditor.tsx
Normal file
195
frontend/src/components/RichTextEditor.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user