Skip to content

Commit f8fe3c7

Browse files
fusion94claude
andcommitted
Add calendar event export/import to admin dashboard
Adds admin-only endpoints for exporting and importing calendar events as JSON, with merge or replace modes. Includes UI in the admin dashboard with Export Events / Import Events buttons. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f3c1c6f commit f8fe3c7

File tree

3 files changed

+193
-0
lines changed

3 files changed

+193
-0
lines changed

server/index.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,48 @@ app.delete('/api/admin/invite/:token', requireAdmin, (req, res) => {
589589
res.json({ success: true });
590590
});
591591

592+
// Admin: Export calendar events
593+
app.get('/api/admin/events/export', requireAdmin, (req, res) => {
594+
const events = db.prepare('SELECT * FROM calendar_events ORDER BY event_date, event_time').all();
595+
res.setHeader('Content-Type', 'application/json');
596+
res.setHeader('Content-Disposition', 'attachment; filename=calendar-events-export.json');
597+
res.json(events);
598+
});
599+
600+
// Admin: Import calendar events
601+
app.post('/api/admin/events/import', requireAdmin, (req, res) => {
602+
const { events, mode } = req.body;
603+
if (!Array.isArray(events)) {
604+
return res.status(400).json({ error: 'events must be an array' });
605+
}
606+
if (mode === 'replace') {
607+
db.prepare('DELETE FROM calendar_events').run();
608+
}
609+
const insert = db.prepare(
610+
`INSERT INTO calendar_events (title, event_date, event_time, description, location, visibility, created_by, created_at, updated_at)
611+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
612+
);
613+
const tx = db.transaction(() => {
614+
for (const e of events) {
615+
if (!e.title || !e.event_date) continue;
616+
insert.run(
617+
e.title,
618+
e.event_date,
619+
e.event_time || null,
620+
e.description || '',
621+
e.location || '',
622+
e.visibility || 'personal',
623+
e.created_by || req.adminUser.username,
624+
e.created_at || Date.now(),
625+
e.updated_at || Date.now()
626+
);
627+
}
628+
});
629+
tx();
630+
const count = db.prepare('SELECT COUNT(*) as count FROM calendar_events').get().count;
631+
res.json({ success: true, count });
632+
});
633+
592634
// Calendar Events: List events for a month
593635
app.get('/api/events', requireAuth, (req, res) => {
594636
const { month } = req.query; // YYYY-MM

src/api.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,29 @@ export const api = {
259259
return res.json();
260260
},
261261

262+
async exportEvents() {
263+
const res = await fetch(`${API_BASE}/admin/events/export`, {
264+
headers: getAuthHeaders(),
265+
});
266+
if (!res.ok) {
267+
throw new Error('Failed to export events');
268+
}
269+
return res.json();
270+
},
271+
272+
async importEvents(events, mode = 'merge') {
273+
const res = await fetch(`${API_BASE}/admin/events/import`, {
274+
method: 'POST',
275+
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
276+
body: JSON.stringify({ events, mode }),
277+
});
278+
if (!res.ok) {
279+
const data = await res.json();
280+
throw new Error(data.error || 'Failed to import events');
281+
}
282+
return res.json();
283+
},
284+
262285
async deleteEvent(id) {
263286
const res = await fetch(`${API_BASE}/events/${id}`, {
264287
method: 'DELETE',

src/pages/AdminDashboard.jsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import DeleteIcon from '@mui/icons-material/Delete';
2929
import DownloadIcon from '@mui/icons-material/Download';
3030
import UploadIcon from '@mui/icons-material/Upload';
3131
import BlockIcon from '@mui/icons-material/Block';
32+
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
3233
import { useAuth } from '../contexts/AuthContext';
3334
import { Navigate } from 'react-router-dom';
3435
import { api } from '../api';
@@ -48,7 +49,11 @@ function AdminDashboard() {
4849
const [invites, setInvites] = useState([]);
4950
const [revokeDialogOpen, setRevokeDialogOpen] = useState(false);
5051
const [selectedInvite, setSelectedInvite] = useState(null);
52+
const [calendarImportDialogOpen, setCalendarImportDialogOpen] = useState(false);
53+
const [calendarImportData, setCalendarImportData] = useState(null);
54+
const [calendarImportMode, setCalendarImportMode] = useState('merge');
5155
const fileInputRef = useRef(null);
56+
const calendarFileInputRef = useRef(null);
5257

5358
useEffect(() => {
5459
loadUsers();
@@ -199,6 +204,63 @@ function AdminDashboard() {
199204
}
200205
};
201206

207+
const handleCalendarExport = async () => {
208+
try {
209+
const data = await api.exportEvents();
210+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
211+
const url = URL.createObjectURL(blob);
212+
const a = document.createElement('a');
213+
a.href = url;
214+
a.download = `sh-underground-calendar-${new Date().toISOString().split('T')[0]}.json`;
215+
document.body.appendChild(a);
216+
a.click();
217+
document.body.removeChild(a);
218+
URL.revokeObjectURL(url);
219+
setSuccess('Calendar events exported successfully');
220+
setTimeout(() => setSuccess(''), 3000);
221+
} catch (err) {
222+
setError(err.message);
223+
}
224+
};
225+
226+
const handleCalendarImportClick = () => {
227+
calendarFileInputRef.current?.click();
228+
};
229+
230+
const handleCalendarFileSelect = (event) => {
231+
const file = event.target.files?.[0];
232+
if (!file) return;
233+
const reader = new FileReader();
234+
reader.onload = (e) => {
235+
try {
236+
const data = JSON.parse(e.target.result);
237+
if (!Array.isArray(data)) {
238+
setError('Calendar import file must contain an array of events');
239+
return;
240+
}
241+
setCalendarImportData(data);
242+
setCalendarImportDialogOpen(true);
243+
setError('');
244+
} catch {
245+
setError('Invalid JSON file');
246+
}
247+
};
248+
reader.readAsText(file);
249+
event.target.value = '';
250+
};
251+
252+
const handleCalendarImportConfirm = async () => {
253+
try {
254+
const result = await api.importEvents(calendarImportData, calendarImportMode);
255+
setCalendarImportDialogOpen(false);
256+
setCalendarImportData(null);
257+
setSuccess(`Calendar import successful: ${result.count} events total`);
258+
setTimeout(() => setSuccess(''), 3000);
259+
} catch (err) {
260+
setError(err.message);
261+
}
262+
};
263+
202264
if (!isAdmin) {
203265
return <Navigate to="/profile" replace />;
204266
}
@@ -452,6 +514,72 @@ function AdminDashboard() {
452514
</Button>
453515
</DialogActions>
454516
</Dialog>
517+
518+
{/* Calendar Backup Section */}
519+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 4, mb: 2, flexWrap: 'wrap' }}>
520+
<CalendarMonthIcon color="primary" />
521+
<Typography variant="h6">
522+
Calendar Backup
523+
</Typography>
524+
<Box sx={{ flexGrow: 1 }} />
525+
<Button
526+
variant="outlined"
527+
startIcon={<DownloadIcon />}
528+
onClick={handleCalendarExport}
529+
size="small"
530+
>
531+
Export Events
532+
</Button>
533+
<Button
534+
variant="outlined"
535+
startIcon={<UploadIcon />}
536+
onClick={handleCalendarImportClick}
537+
size="small"
538+
>
539+
Import Events
540+
</Button>
541+
<input
542+
type="file"
543+
ref={calendarFileInputRef}
544+
onChange={handleCalendarFileSelect}
545+
accept=".json"
546+
style={{ display: 'none' }}
547+
/>
548+
</Box>
549+
550+
{/* Calendar Import Dialog */}
551+
<Dialog open={calendarImportDialogOpen} onClose={() => setCalendarImportDialogOpen(false)} maxWidth="sm" fullWidth>
552+
<DialogTitle>Import Calendar Events</DialogTitle>
553+
<DialogContent>
554+
<Typography sx={{ mb: 2 }}>
555+
Found {calendarImportData ? calendarImportData.length : 0} events in the file.
556+
</Typography>
557+
<FormControl component="fieldset">
558+
<FormLabel component="legend">Import Mode</FormLabel>
559+
<RadioGroup
560+
value={calendarImportMode}
561+
onChange={(e) => setCalendarImportMode(e.target.value)}
562+
>
563+
<FormControlLabel
564+
value="merge"
565+
control={<Radio />}
566+
label="Merge - Add imported events alongside existing ones"
567+
/>
568+
<FormControlLabel
569+
value="replace"
570+
control={<Radio />}
571+
label="Replace - Delete all existing events and replace with file data"
572+
/>
573+
</RadioGroup>
574+
</FormControl>
575+
</DialogContent>
576+
<DialogActions>
577+
<Button onClick={() => setCalendarImportDialogOpen(false)}>Cancel</Button>
578+
<Button onClick={handleCalendarImportConfirm} variant="contained">
579+
Import
580+
</Button>
581+
</DialogActions>
582+
</Dialog>
455583
</Box>
456584
);
457585
}

0 commit comments

Comments
 (0)