v2: MongoDB backend, /manage dashboard, board auth
- Replace hardcoded data with MongoDB + Express 5 API - Add /manage dashboard with board member login, drag-and-drop reordering, image preview on upload - Board members log in via email with forced password change - Forgot/reset password via email - Rate limiting (5 attempts per 5 min) - Separate MongoDB container with persistent named volume - Frontend visually unchanged (100% compatible)
This commit is contained in:
commit
cb610bb9c1
38 changed files with 4200 additions and 288 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
.DS_Store
|
||||
build/
|
||||
node_modules/
|
||||
server/dist/
|
||||
|
|
|
|||
65
Dockerfile
65
Dockerfile
|
|
@ -1,71 +1,62 @@
|
|||
# Build stage
|
||||
FROM node:14 AS build
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
# Add a cache busting argument
|
||||
ARG CACHEBUST=1
|
||||
RUN echo "Cache bust: $CACHEBUST"
|
||||
|
||||
# Build tools needed for argon2 native compilation
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
|
||||
# Install dependencies first (cache layer)
|
||||
COPY .env ./.env
|
||||
COPY package*.json ./
|
||||
COPY client/package*.json ./client/
|
||||
COPY server/package*.json ./server/
|
||||
|
||||
# Install dependencies
|
||||
RUN cd client && npm cache clean --force && rm -rf node_modules && npm install
|
||||
RUN cd client && npm install
|
||||
RUN cd server && npm install
|
||||
|
||||
# Copy the entire client directory structure
|
||||
# Copy static assets
|
||||
COPY client/public/headshots ./client/public/headshots
|
||||
COPY client/public/logos ./client/public/logos
|
||||
COPY client/public/photos ./client/public/photos
|
||||
COPY client/public/sponsors ./client/public/sponsors
|
||||
COPY client/public/videos ./client/public/videos
|
||||
COPY client/public/minutes ./client/public/minutes
|
||||
COPY client/public/logos ./client/public/logos
|
||||
COPY client/public/photos ./client/public/photos
|
||||
COPY client/public/sponsors ./client/public/sponsors
|
||||
COPY client/public/videos ./client/public/videos
|
||||
COPY client/public/minutes ./client/public/minutes
|
||||
COPY client/public/*.* ./client/public/
|
||||
|
||||
|
||||
# Copy source code
|
||||
COPY client/src ./client/src
|
||||
COPY client/*.js ./client/
|
||||
# Copy source
|
||||
COPY client/src ./client/src
|
||||
COPY client/*.js ./client/
|
||||
COPY client/*.json ./client/
|
||||
COPY server ./server
|
||||
|
||||
# Install required packages
|
||||
RUN npm install nodemailer dotenv date-fns react-icons
|
||||
COPY server/src ./server/src
|
||||
COPY server/tsconfig.json ./server/
|
||||
|
||||
# Build client
|
||||
RUN cd client && npm run build
|
||||
|
||||
# Install all dependencies including devDependencies
|
||||
RUN cd server && npm install
|
||||
|
||||
# Build server
|
||||
RUN cd server && npm run build
|
||||
|
||||
# Remove devDependencies
|
||||
RUN cd server && npm prune --production
|
||||
RUN cd server && npm run build && npm prune --production
|
||||
|
||||
# Production stage
|
||||
FROM node:14-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built assets and static files from build stage
|
||||
COPY --from=build /app/client/build ./client/build
|
||||
COPY --from=build /app/server ./server
|
||||
COPY --from=build /app/server/dist ./server/dist
|
||||
COPY --from=build /app/server/node_modules ./server/node_modules
|
||||
COPY --from=build /app/server/package.json ./server/package.json
|
||||
COPY --from=build /app/.env ./.env
|
||||
|
||||
# Set working directory to server
|
||||
RUN mkdir -p uploads/headshots uploads/photos uploads/sponsors uploads/minutes
|
||||
|
||||
WORKDIR /app/server
|
||||
|
||||
# Install production dependencies
|
||||
RUN npm install --only=production
|
||||
|
||||
# Expose port
|
||||
EXPOSE 801
|
||||
|
||||
# Start the server
|
||||
CMD ["npm", "start"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
|
|
|||
|
|
@ -14,29 +14,35 @@ import VideoGallery from './components/VideoGallery';
|
|||
import Resources from './components/Resources';
|
||||
/* import ComingSoon from './components/ComingSoon'; */
|
||||
import JoinMCDi from './components/JoinMCDi';
|
||||
import Manage from './components/Manage';
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<Router>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Header />
|
||||
<main className="flex-grow px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/minutes" element={<Minutes />} />
|
||||
<Route path="/about" element={<AboutUs />} />
|
||||
<Route path="/bylaws" element={<Bylaws />} />
|
||||
<Route path="/sponsors" element={<Sponsors />} />
|
||||
<Route path="/meet-board" element={<MeetBoard />} />
|
||||
<Route path="/gallery" element={<Gallery />} />
|
||||
<Route path="/videos" element={<VideoGallery />} />
|
||||
<Route path="/resources" element={<Resources />} />
|
||||
<Route path="/joinmcdi" element={<JoinMCDi />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/manage/*" element={<Manage />} />
|
||||
<Route path="/*" element={
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Header />
|
||||
<main className="flex-grow px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/minutes" element={<Minutes />} />
|
||||
<Route path="/about" element={<AboutUs />} />
|
||||
<Route path="/bylaws" element={<Bylaws />} />
|
||||
<Route path="/sponsors" element={<Sponsors />} />
|
||||
<Route path="/meet-board" element={<MeetBoard />} />
|
||||
<Route path="/gallery" element={<Gallery />} />
|
||||
<Route path="/videos" element={<VideoGallery />} />
|
||||
<Route path="/resources" element={<Resources />} />
|
||||
<Route path="/joinmcdi" element={<JoinMCDi />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import EventCard from './EventCard';
|
||||
import { parseISO, startOfDay, isSameDay, isAfter, format, startOfMonth } from 'date-fns';
|
||||
import { events, Event } from '../eventData';
|
||||
import { Event } from '../eventData';
|
||||
|
||||
export const formatTime = (dateString: string, time: string): string => {
|
||||
const date = parseISO(dateString);
|
||||
|
|
@ -60,6 +60,13 @@ const Calendar: React.FC = () => {
|
|||
});
|
||||
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
|
||||
const calendarRef = useRef<HTMLDivElement>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/events')
|
||||
.then(r => r.json())
|
||||
.then(setEvents);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface GalleryItem {
|
||||
|
|
@ -7,62 +7,18 @@ interface GalleryItem {
|
|||
alt: string;
|
||||
}
|
||||
|
||||
// Gallery photos from community events
|
||||
const galleryData: GalleryItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
imageUrl: '/photos/0001.jpg',
|
||||
alt: 'MCDi community gathering',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
imageUrl: '/photos/0002.jpg',
|
||||
alt: 'MCDi community event photo',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
imageUrl: '/photos/0003.jpg',
|
||||
alt: 'MCDi community event photo',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
imageUrl: '/photos/0004.jpg',
|
||||
alt: 'MCDi community event photo',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
imageUrl: '/photos/0005.jpg',
|
||||
alt: 'MCDi community event photo',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
imageUrl: '/photos/0006.jpg',
|
||||
alt: 'MCDi community event photo',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
imageUrl: '/photos/0007.jpg',
|
||||
alt: 'MCDi community event photo',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
imageUrl: '/photos/0008.jpg',
|
||||
alt: 'MCDi community event photo',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
imageUrl: '/photos/0009.jpg',
|
||||
alt: 'MCDi community event photo',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
imageUrl: '/photos/0010.jpg',
|
||||
alt: 'MCDi community event photo',
|
||||
},
|
||||
];
|
||||
|
||||
const Gallery: React.FC = () => {
|
||||
const [selectedImage, setSelectedImage] = useState<GalleryItem | null>(null);
|
||||
const [galleryData, setGalleryData] = useState<GalleryItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/gallery')
|
||||
.then(r => r.json())
|
||||
.then((items: Array<GalleryItem & { _id: string }>) =>
|
||||
setGalleryData(items.map((item, i) => ({ ...item, id: i + 1 })))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const openImage = (item: GalleryItem) => {
|
||||
setSelectedImage(item);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { motion } from 'framer-motion';
|
|||
import ImageCarousel from './ImageCarousel';
|
||||
import EventCard from './EventCard';
|
||||
import { parseISO, startOfDay, isSameDay, isAfter } from 'date-fns';
|
||||
import { events, Event } from '../eventData';
|
||||
import { Event } from '../eventData';
|
||||
import { formatDateRange } from './Calendar';
|
||||
|
||||
const isEventUpcoming = (eventDate: string) => {
|
||||
|
|
@ -16,11 +16,15 @@ const Home: React.FC = () => {
|
|||
const [upcomingEvents, setUpcomingEvents] = useState<Event[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const filteredEvents = events
|
||||
.filter(event => isEventUpcoming(event.date))
|
||||
.sort((a, b) => parseISO(a.date).getTime() - parseISO(b.date).getTime())
|
||||
.slice(0, 6);
|
||||
setUpcomingEvents(filteredEvents);
|
||||
fetch('/api/events')
|
||||
.then(r => r.json())
|
||||
.then((allEvents: Event[]) => {
|
||||
const filteredEvents = allEvents
|
||||
.filter(event => isEventUpcoming(event.date))
|
||||
.sort((a, b) => parseISO(a.date).getTime() - parseISO(b.date).getTime())
|
||||
.slice(0, 6);
|
||||
setUpcomingEvents(filteredEvents);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
|
|||
822
client/src/components/Manage.tsx
Normal file
822
client/src/components/Manage.tsx
Normal file
|
|
@ -0,0 +1,822 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface User { id: string; name: string; email: string; role: string; }
|
||||
interface BoardMember { _id: string; name: string; position: string; email: string; image: string; bio: string; order: number; }
|
||||
interface Event { _id: string; date: string; title: string; description: string; startTime: string; endTime: string; pointOfContact: string; email: string; address: string; }
|
||||
interface GalleryItem { _id: string; imageUrl: string; alt: string; order: number; }
|
||||
interface Sponsor { _id: string; name: string; logoPath: string; description: string; websiteUrl?: string; facebookUrl?: string; instagramUrl?: string; order: number; }
|
||||
interface Minute { _id: string; date: string; meetingType: string; location: string; fileUrl: string; order: number; }
|
||||
interface Member { _id: string; firstName: string; lastName: string; email: string; phone?: string; address?: string; membershipType: string; status: string; joinDate: string; notes?: string; }
|
||||
|
||||
type Tab = 'board' | 'events' | 'gallery' | 'sponsors' | 'minutes' | 'members';
|
||||
type AuthScreen = 'login' | 'forgot' | 'reset' | 'changePassword';
|
||||
|
||||
// ── Auth helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const getToken = () => localStorage.getItem('mcdi_token');
|
||||
const authHeaders = () => ({ Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json' });
|
||||
|
||||
async function api<T>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, { headers: authHeaders(), ...opts });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Shared UI ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn: React.FC<{ onClick?: () => void; type?: 'button'|'submit'; color?: string; disabled?: boolean; small?: boolean; children: React.ReactNode }> =
|
||||
({ onClick, type = 'button', color = 'bg-[#8C1D40] hover:bg-[#6B1631]', disabled, small, children }) => (
|
||||
<button type={type} onClick={onClick} disabled={disabled}
|
||||
className={`${color} text-white ${small ? 'text-xs px-2 py-1' : 'text-sm px-3 py-1.5'} rounded transition disabled:opacity-50`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<label className="block text-sm">
|
||||
<span className="text-gray-700 font-medium">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
const Input: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = (props) => (
|
||||
<input {...props} className={`w-full border rounded px-2 py-1.5 text-sm ${props.className ?? ''}`} />
|
||||
);
|
||||
|
||||
const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = (props) => (
|
||||
<textarea {...props} className={`w-full border rounded px-2 py-1.5 text-sm ${props.className ?? ''}`} />
|
||||
);
|
||||
|
||||
// Image file picker with instant preview
|
||||
const ImagePicker: React.FC<{
|
||||
label: string;
|
||||
currentSrc?: string;
|
||||
onFile: (f: File | null) => void;
|
||||
}> = ({ label, currentSrc, onFile }) => {
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
onFile(file);
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreview(url);
|
||||
} else {
|
||||
setPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const displaySrc = preview ?? currentSrc;
|
||||
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="flex items-start gap-3">
|
||||
{displaySrc && (
|
||||
<img src={displaySrc} alt="preview" className="w-16 h-16 object-cover rounded border flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<Input type="file" accept="image/*" onChange={handleChange} />
|
||||
{!preview && currentSrc && <p className="text-xs text-gray-400 mt-1">Current photo kept unless you choose a new one</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
// Drag-and-drop list — wraps any array of items
|
||||
function useDragReorder<T extends { _id: string }>(
|
||||
items: T[],
|
||||
setItems: React.Dispatch<React.SetStateAction<T[]>>,
|
||||
saveOrder: (ids: string[]) => Promise<void>
|
||||
) {
|
||||
const dragIndex = useRef<number | null>(null);
|
||||
const [dragOver, setDragOver] = useState<number | null>(null);
|
||||
|
||||
const onDragStart = (i: number) => { dragIndex.current = i; };
|
||||
const onDragOver = (e: React.DragEvent, i: number) => { e.preventDefault(); setDragOver(i); };
|
||||
const onDrop = async (i: number) => {
|
||||
const from = dragIndex.current;
|
||||
if (from === null || from === i) { setDragOver(null); return; }
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(i, 0, moved);
|
||||
setItems(next);
|
||||
setDragOver(null);
|
||||
dragIndex.current = null;
|
||||
await saveOrder(next.map(x => x._id));
|
||||
};
|
||||
const onDragEnd = () => setDragOver(null);
|
||||
|
||||
return { dragOver, onDragStart, onDragOver, onDrop, onDragEnd };
|
||||
}
|
||||
|
||||
// ── Auth screens ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Login: React.FC<{ onLogin: (token: string, user: User) => void; onTempToken: (t: string) => void; onForgot: () => void }> = ({ onLogin, onTempToken, onForgot }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data: { token?: string; user?: User; mustChangePassword?: boolean; tempToken?: string } = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
}).then(r => r.json().then(d => { if (!r.ok) throw new Error(d.error ?? 'Login failed'); return d; }));
|
||||
|
||||
if (data.mustChangePassword && data.tempToken) {
|
||||
onTempToken(data.tempToken);
|
||||
} else if (data.token && data.user) {
|
||||
onLogin(data.token, data.user);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm">
|
||||
<h1 className="text-2xl font-bold text-[#8C1D40] mb-2 text-center">MCDi Management</h1>
|
||||
<p className="text-sm text-gray-500 text-center mb-6">Sign in with your board email</p>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Input type="email" placeholder="Email address" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
<Input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||
<button className="w-full bg-[#8C1D40] text-white py-2 rounded hover:bg-[#6B1631] transition" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
<button onClick={onForgot} className="w-full text-center text-sm text-[#8C1D40] hover:underline mt-4">
|
||||
Forgot password / Set your password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ForgotPassword: React.FC<{ onBack: () => void }> = ({ onBack }) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
await fetch('/api/auth/forgot-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }),
|
||||
});
|
||||
setSent(true); setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm">
|
||||
<h1 className="text-xl font-bold text-[#8C1D40] mb-4 text-center">Reset Password</h1>
|
||||
{sent ? (
|
||||
<div className="text-center">
|
||||
<p className="text-green-700 mb-4">If that email is registered, a reset link has been sent. Check your inbox.</p>
|
||||
<button onClick={onBack} className="text-sm text-[#8C1D40] hover:underline">← Back to sign in</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<p className="text-sm text-gray-500">Enter your board email address and we'll send you a link to set your password.</p>
|
||||
<Input type="email" placeholder="Your board email" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
<button className="w-full bg-[#8C1D40] text-white py-2 rounded hover:bg-[#6B1631] transition" disabled={loading}>
|
||||
{loading ? 'Sending…' : 'Send Reset Link'}
|
||||
</button>
|
||||
<button type="button" onClick={onBack} className="w-full text-center text-sm text-gray-500 hover:underline">← Back to sign in</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResetPassword: React.FC<{ token: string; onLogin: (token: string, user: User) => void }> = ({ token, onLogin }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password, confirmPassword: confirm }),
|
||||
}).then(r => r.json().then(d => { if (!r.ok) throw new Error(d.error ?? 'Failed'); return d; }));
|
||||
localStorage.setItem('mcdi_token', data.token);
|
||||
window.history.replaceState({}, '', '/manage');
|
||||
onLogin(data.token, data.user);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm">
|
||||
<h1 className="text-xl font-bold text-[#8C1D40] mb-4 text-center">Set Your Password</h1>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Input type="password" placeholder="New password (min 8 chars)" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
|
||||
<Input type="password" placeholder="Confirm password" value={confirm} onChange={e => setConfirm(e.target.value)} required />
|
||||
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||
<button className="w-full bg-[#8C1D40] text-white py-2 rounded hover:bg-[#6B1631] transition" disabled={loading}>
|
||||
{loading ? 'Saving…' : 'Set Password & Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ForceChangePassword: React.FC<{ tempToken: string; onLogin: (token: string, user: User) => void }> = ({ tempToken, onLogin }) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${tempToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password, confirmPassword: confirm }),
|
||||
}).then(r => r.json().then(d => { if (!r.ok) throw new Error(d.error ?? 'Failed'); return d; }));
|
||||
localStorage.setItem('mcdi_token', data.token);
|
||||
onLogin(data.token, data.user);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm">
|
||||
<h1 className="text-xl font-bold text-[#8C1D40] mb-2 text-center">Welcome to MCDi Management</h1>
|
||||
<p className="text-sm text-gray-500 text-center mb-6">Please choose a password to get started.</p>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Input type="password" placeholder="New password (min 8 chars)" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
|
||||
<Input type="password" placeholder="Confirm password" value={confirm} onChange={e => setConfirm(e.target.value)} required />
|
||||
{error && <p className="text-red-600 text-sm">{error}</p>}
|
||||
<button className="w-full bg-[#8C1D40] text-white py-2 rounded hover:bg-[#6B1631] transition" disabled={loading}>
|
||||
{loading ? 'Saving…' : 'Set Password & Continue'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Board tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BoardTab: React.FC = () => {
|
||||
const [members, setMembers] = useState<BoardMember[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<BoardMember> | null>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
|
||||
const load = useCallback(() => api<BoardMember[]>('/board').then(setMembers), []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const saveOrder = useCallback(async (ids: string[]) => {
|
||||
await api('/board/reorder', { method: 'PATCH', body: JSON.stringify({ ids }) });
|
||||
}, []);
|
||||
|
||||
const drag = useDragReorder(members, setMembers, saveOrder);
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
const form = new FormData();
|
||||
Object.entries(editing).forEach(([k, v]) => { if (k !== '_id' && v !== undefined) form.append(k, String(v)); });
|
||||
if (imageFile) form.append('image', imageFile);
|
||||
const url = editing._id ? `/api/board/${editing._id}` : '/api/board';
|
||||
await fetch(url, { method: editing._id ? 'PUT' : 'POST', headers: { Authorization: `Bearer ${getToken()}` }, body: form });
|
||||
setEditing(null); setImageFile(null); load();
|
||||
};
|
||||
|
||||
const del = async (id: string) => {
|
||||
if (!window.confirm('Remove this board member? They will lose dashboard access.')) return;
|
||||
await api(`/board/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[#8C1D40]">Board Members</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Drag cards to reorder. New members receive a welcome email to set their password.</p>
|
||||
</div>
|
||||
<Btn onClick={() => setEditing({ name:'', position:'', email:'', image:'', bio:'', order: members.length })}>+ Add Member</Btn>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<form onSubmit={save} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="Name"><Input required value={editing.name ?? ''} onChange={e => setEditing(p => ({ ...p, name: e.target.value }))} /></Field>
|
||||
<Field label="Position"><Input required value={editing.position ?? ''} onChange={e => setEditing(p => ({ ...p, position: e.target.value }))} /></Field>
|
||||
<Field label="Email (used to log into dashboard)"><Input required type="email" value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} /></Field>
|
||||
<ImagePicker label="Photo" currentSrc={editing.image} onFile={setImageFile} />
|
||||
<Field label="Bio" ><Textarea required rows={4} value={editing.bio ?? ''} onChange={e => setEditing(p => ({ ...p, bio: e.target.value }))} /></Field>
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Btn type="submit">Save{!editing._id ? ' & Send Welcome Email' : ''}</Btn>
|
||||
<Btn color="bg-gray-400 hover:bg-gray-500" onClick={() => { setEditing(null); setImageFile(null); }}>Cancel</Btn>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{members.map((m, i) => (
|
||||
<div key={m._id}
|
||||
draggable
|
||||
onDragStart={() => drag.onDragStart(i)}
|
||||
onDragOver={e => drag.onDragOver(e, i)}
|
||||
onDrop={() => drag.onDrop(i)}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
className={`flex items-center gap-3 bg-white border rounded p-3 cursor-grab active:cursor-grabbing transition-all ${drag.dragOver === i ? 'border-[#8C1D40] bg-[#8C1D40]/5' : ''}`}
|
||||
>
|
||||
<span className="text-gray-300 select-none">⠿</span>
|
||||
<img src={m.image} alt={m.name} className="w-12 h-12 rounded-full object-cover flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{m.name}</p>
|
||||
<p className="text-sm text-gray-500">{m.position} · {m.email}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Btn onClick={() => setEditing(m)}>Edit</Btn>
|
||||
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(m._id)}>Delete</Btn>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Events tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
const EventsTab: React.FC = () => {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<Event> | null>(null);
|
||||
|
||||
const load = useCallback(() => api<Event[]>('/events').then(setEvents), []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const blank: Partial<Event> = { date:'', title:'', description:'', startTime:'09:00', endTime:'10:00', pointOfContact:'', email:'', address:'' };
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
const { _id, ...body } = editing as Event;
|
||||
await api(_id ? `/events/${_id}` : '/events', { method: _id ? 'PUT' : 'POST', body: JSON.stringify(body) });
|
||||
setEditing(null); load();
|
||||
};
|
||||
|
||||
const del = async (id: string) => {
|
||||
if (!window.confirm('Delete this event?')) return;
|
||||
await api(`/events/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-[#8C1D40]">Events</h2>
|
||||
<Btn onClick={() => setEditing(blank)}>+ Add Event</Btn>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<form onSubmit={save} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="Title"><Input required value={editing.title ?? ''} onChange={e => setEditing(p => ({ ...p, title: e.target.value }))} /></Field>
|
||||
<Field label="Date"><Input required type="date" value={editing.date ?? ''} onChange={e => setEditing(p => ({ ...p, date: e.target.value }))} /></Field>
|
||||
<Field label="Start Time"><Input required type="time" value={editing.startTime ?? ''} onChange={e => setEditing(p => ({ ...p, startTime: e.target.value }))} /></Field>
|
||||
<Field label="End Time"><Input required type="time" value={editing.endTime ?? ''} onChange={e => setEditing(p => ({ ...p, endTime: e.target.value }))} /></Field>
|
||||
<Field label="Contact Name"><Input required value={editing.pointOfContact ?? ''} onChange={e => setEditing(p => ({ ...p, pointOfContact: e.target.value }))} /></Field>
|
||||
<Field label="Contact Email"><Input required type="email" value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} /></Field>
|
||||
<Field label="Address"><Input required value={editing.address ?? ''} onChange={e => setEditing(p => ({ ...p, address: e.target.value }))} /></Field>
|
||||
<Field label="Description"><Textarea required rows={2} value={editing.description ?? ''} onChange={e => setEditing(p => ({ ...p, description: e.target.value }))} /></Field>
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Btn type="submit">Save</Btn>
|
||||
<Btn color="bg-gray-400 hover:bg-gray-500" onClick={() => setEditing(null)}>Cancel</Btn>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{events.map(ev => (
|
||||
<div key={ev._id} className="flex items-center justify-between bg-white border rounded p-3">
|
||||
<div>
|
||||
<p className="font-medium">{ev.title}</p>
|
||||
<p className="text-sm text-gray-500">{ev.date} · {ev.startTime}–{ev.endTime} · {ev.address}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Btn onClick={() => setEditing(ev)}>Edit</Btn>
|
||||
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(ev._id)}>Delete</Btn>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Gallery tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
const GalleryTab: React.FC = () => {
|
||||
const [items, setItems] = useState<GalleryItem[]>([]);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [alt, setAlt] = useState('MCDi community event photo');
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => api<GalleryItem[]>('/gallery').then(setItems), []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] ?? null;
|
||||
setImageFile(file);
|
||||
setPreview(file ? URL.createObjectURL(file) : null);
|
||||
};
|
||||
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!imageFile) return;
|
||||
const form = new FormData();
|
||||
form.append('image', imageFile); form.append('alt', alt);
|
||||
await fetch('/api/gallery', { method: 'POST', headers: { Authorization: `Bearer ${getToken()}` }, body: form });
|
||||
setImageFile(null); setPreview(null); setAlt('MCDi community event photo'); load();
|
||||
};
|
||||
|
||||
const del = async (id: string) => {
|
||||
if (!window.confirm('Remove this photo?')) return;
|
||||
await api(`/gallery/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[#8C1D40] mb-4">Gallery</h2>
|
||||
<form onSubmit={add} className="bg-gray-50 border rounded p-4 mb-4 flex flex-wrap gap-3 items-end">
|
||||
<div className="flex items-start gap-3">
|
||||
{preview && <img src={preview} alt="preview" className="w-20 h-20 object-cover rounded border" />}
|
||||
<Field label="Photo">
|
||||
<Input required type="file" accept="image/*" onChange={handleFile} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Alt text"><Input value={alt} onChange={e => setAlt(e.target.value)} /></Field>
|
||||
<Btn type="submit" disabled={!imageFile}>Upload Photo</Btn>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{items.map(item => (
|
||||
<div key={item._id} className="relative group">
|
||||
<img src={item.imageUrl} alt={item.alt} className="w-full aspect-square object-cover rounded" />
|
||||
<button onClick={() => del(item._id)} className="absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 text-xs opacity-0 group-hover:opacity-100 transition">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Sponsors tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SponsorsTab: React.FC = () => {
|
||||
const [sponsors, setSponsors] = useState<Sponsor[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
|
||||
const load = useCallback(() => api<Sponsor[]>('/sponsors').then(setSponsors), []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
const form = new FormData();
|
||||
Object.entries(editing).forEach(([k, v]) => { if (k !== '_id' && v !== undefined) form.append(k, String(v)); });
|
||||
if (logoFile) form.append('logo', logoFile);
|
||||
const url = editing._id ? `/api/sponsors/${editing._id}` : '/api/sponsors';
|
||||
await fetch(url, { method: editing._id ? 'PUT' : 'POST', headers: { Authorization: `Bearer ${getToken()}` }, body: form });
|
||||
setEditing(null); setLogoFile(null); load();
|
||||
};
|
||||
|
||||
const del = async (id: string) => {
|
||||
if (!window.confirm('Remove this sponsor?')) return;
|
||||
await api(`/sponsors/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-[#8C1D40]">Sponsors</h2>
|
||||
<Btn onClick={() => setEditing({ name:'', logoPath:'', description:'', websiteUrl:'', facebookUrl:'', instagramUrl:'', order: sponsors.length })}>+ Add Sponsor</Btn>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<form onSubmit={save} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="Name"><Input required value={editing.name ?? ''} onChange={e => setEditing(p => ({ ...p, name: e.target.value }))} /></Field>
|
||||
<ImagePicker label="Logo" currentSrc={editing.logoPath} onFile={setLogoFile} />
|
||||
<Field label="Description"><Textarea required rows={3} value={editing.description ?? ''} onChange={e => setEditing(p => ({ ...p, description: e.target.value }))} /></Field>
|
||||
<Field label="Website URL"><Input type="url" value={editing.websiteUrl ?? ''} onChange={e => setEditing(p => ({ ...p, websiteUrl: e.target.value }))} /></Field>
|
||||
<Field label="Facebook URL"><Input type="url" value={editing.facebookUrl ?? ''} onChange={e => setEditing(p => ({ ...p, facebookUrl: e.target.value }))} /></Field>
|
||||
<Field label="Instagram URL"><Input type="url" value={editing.instagramUrl ?? ''} onChange={e => setEditing(p => ({ ...p, instagramUrl: e.target.value }))} /></Field>
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Btn type="submit">Save</Btn>
|
||||
<Btn color="bg-gray-400 hover:bg-gray-500" onClick={() => { setEditing(null); setLogoFile(null); }}>Cancel</Btn>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{sponsors.map(s => (
|
||||
<div key={s._id} className="flex items-center gap-3 bg-white border rounded p-3">
|
||||
<img src={s.logoPath} alt={s.name} className="w-16 h-10 object-contain flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{s.name}</p>
|
||||
<p className="text-sm text-gray-500 truncate">{s.websiteUrl}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<Btn onClick={() => setEditing(s)}>Edit</Btn>
|
||||
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(s._id)}>Delete</Btn>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Minutes tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MinutesTab: React.FC = () => {
|
||||
const [minutes, setMinutes] = useState<Minute[]>([]);
|
||||
const [pdfFile, setPdfFile] = useState<File | null>(null);
|
||||
const [form, setForm] = useState({ date: '', meetingType: 'Board Meeting', location: '' });
|
||||
|
||||
const load = useCallback(() => api<Minute[]>('/minutes').then(setMinutes), []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const saveOrder = useCallback(async (ids: string[]) => {
|
||||
await api('/minutes/reorder', { method: 'PATCH', body: JSON.stringify({ ids }) });
|
||||
}, []);
|
||||
|
||||
const drag = useDragReorder(minutes, setMinutes, saveOrder);
|
||||
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!pdfFile) return;
|
||||
const fd = new FormData();
|
||||
Object.entries(form).forEach(([k, v]) => fd.append(k, v));
|
||||
fd.append('file', pdfFile);
|
||||
await fetch('/api/minutes', { method: 'POST', headers: { Authorization: `Bearer ${getToken()}` }, body: fd });
|
||||
setPdfFile(null); setForm({ date:'', meetingType:'Board Meeting', location:'' }); load();
|
||||
};
|
||||
|
||||
const del = async (id: string) => {
|
||||
if (!window.confirm('Delete these minutes?')) return;
|
||||
await api(`/minutes/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-[#8C1D40]">Meeting Minutes</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Drag cards to reorder how they appear on the public site.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={add} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="Date (MM/DD/YYYY)"><Input required value={form.date} onChange={e => setForm(p => ({ ...p, date: e.target.value }))} placeholder="01/15/2026" /></Field>
|
||||
<Field label="Meeting Type">
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={form.meetingType} onChange={e => setForm(p => ({ ...p, meetingType: e.target.value }))}>
|
||||
<option>Board Meeting</option>
|
||||
<option>Membership Meeting</option>
|
||||
<option>Special Meeting</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Location"><Input required value={form.location} onChange={e => setForm(p => ({ ...p, location: e.target.value }))} /></Field>
|
||||
<Field label="PDF File"><Input required type="file" accept=".pdf" onChange={e => setPdfFile(e.target.files?.[0] ?? null)} /></Field>
|
||||
<div className="md:col-span-2">
|
||||
<Btn type="submit" disabled={!pdfFile}>Upload Minutes</Btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
{minutes.map((m, i) => (
|
||||
<div key={m._id}
|
||||
draggable
|
||||
onDragStart={() => drag.onDragStart(i)}
|
||||
onDragOver={e => drag.onDragOver(e, i)}
|
||||
onDrop={() => drag.onDrop(i)}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
className={`flex items-center justify-between bg-white border rounded p-3 cursor-grab active:cursor-grabbing transition-all ${drag.dragOver === i ? 'border-[#8C1D40] bg-[#8C1D40]/5' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-300 select-none">⠿</span>
|
||||
<div>
|
||||
<p className="font-medium">{m.meetingType} — {m.date}</p>
|
||||
<p className="text-sm text-gray-500">{m.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a href={m.fileUrl} target="_blank" rel="noreferrer" className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 rounded transition">View</a>
|
||||
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(m._id)}>Delete</Btn>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Members tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MembersTab: React.FC = () => {
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [editing, setEditing] = useState<Partial<Member> | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const load = useCallback(() => api<Member[]>('/members').then(setMembers), []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const blank: Partial<Member> = { firstName:'', lastName:'', email:'', phone:'', address:'', membershipType:'regular', status:'active', joinDate: new Date().toISOString().split('T')[0], notes:'' };
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
const { _id, ...body } = editing as Member;
|
||||
await api(_id ? `/members/${_id}` : '/members', { method: _id ? 'PUT' : 'POST', body: JSON.stringify(body) });
|
||||
setEditing(null); load();
|
||||
};
|
||||
|
||||
const del = async (id: string) => {
|
||||
if (!window.confirm('Remove this member?')) return;
|
||||
await api(`/members/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
};
|
||||
|
||||
const filtered = members.filter(m =>
|
||||
`${m.firstName} ${m.lastName} ${m.email}`.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-[#8C1D40]">Members ({filtered.length})</h2>
|
||||
<Btn onClick={() => setEditing(blank)}>+ Add Member</Btn>
|
||||
</div>
|
||||
|
||||
<Input placeholder="Search by name or email…" value={search} onChange={e => setSearch(e.target.value)} className="mb-4" />
|
||||
|
||||
{editing && (
|
||||
<form onSubmit={save} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="First Name"><Input required value={editing.firstName ?? ''} onChange={e => setEditing(p => ({ ...p, firstName: e.target.value }))} /></Field>
|
||||
<Field label="Last Name"><Input required value={editing.lastName ?? ''} onChange={e => setEditing(p => ({ ...p, lastName: e.target.value }))} /></Field>
|
||||
<Field label="Email"><Input required type="email" value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} /></Field>
|
||||
<Field label="Phone"><Input value={editing.phone ?? ''} onChange={e => setEditing(p => ({ ...p, phone: e.target.value }))} /></Field>
|
||||
<Field label="Address"><Input value={editing.address ?? ''} onChange={e => setEditing(p => ({ ...p, address: e.target.value }))} /></Field>
|
||||
<Field label="Join Date"><Input type="date" value={typeof editing.joinDate === 'string' ? editing.joinDate.split('T')[0] : ''} onChange={e => setEditing(p => ({ ...p, joinDate: e.target.value }))} /></Field>
|
||||
<Field label="Membership Type">
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={editing.membershipType ?? 'regular'} onChange={e => setEditing(p => ({ ...p, membershipType: e.target.value }))}>
|
||||
<option value="regular">Regular</option>
|
||||
<option value="honorary">Honorary</option>
|
||||
<option value="board">Board</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={editing.status ?? 'active'} onChange={e => setEditing(p => ({ ...p, status: e.target.value }))}>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Notes"><Textarea rows={2} value={editing.notes ?? ''} onChange={e => setEditing(p => ({ ...p, notes: e.target.value }))} /></Field>
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Btn type="submit">Save</Btn>
|
||||
<Btn color="bg-gray-400 hover:bg-gray-500" onClick={() => setEditing(null)}>Cancel</Btn>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#8C1D40] text-white">
|
||||
<tr>{['Name','Email','Type','Status','Joined',''].map(h => <th key={h} className="py-2 px-3 text-left">{h}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(m => (
|
||||
<tr key={m._id} className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 px-3">{m.firstName} {m.lastName}</td>
|
||||
<td className="py-2 px-3">{m.email}</td>
|
||||
<td className="py-2 px-3 capitalize">{m.membershipType}</td>
|
||||
<td className="py-2 px-3"><span className={`px-2 py-0.5 rounded-full text-xs ${m.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>{m.status}</span></td>
|
||||
<td className="py-2 px-3">{m.joinDate ? new Date(m.joinDate).toLocaleDateString() : ''}</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex gap-2">
|
||||
<Btn onClick={() => setEditing(m)}>Edit</Btn>
|
||||
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(m._id)}>Delete</Btn>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Main dashboard ────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'board', label: 'Board' },
|
||||
{ id: 'events', label: 'Events' },
|
||||
{ id: 'gallery', label: 'Gallery' },
|
||||
{ id: 'sponsors', label: 'Sponsors' },
|
||||
{ id: 'minutes', label: 'Minutes' },
|
||||
{ id: 'members', label: 'Members' },
|
||||
];
|
||||
|
||||
const Dashboard: React.FC<{ user: User; onLogout: () => void }> = ({ user, onLogout }) => {
|
||||
const [tab, setTab] = useState<Tab>('board');
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<header className="bg-[#8C1D40] text-white px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-bold text-lg">MCDi Management</span>
|
||||
<span className="text-sm opacity-75">— {user.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a href="/" className="text-sm underline opacity-75 hover:opacity-100">← Public Site</a>
|
||||
<button onClick={onLogout} className="text-sm bg-white/20 hover:bg-white/30 px-3 py-1 rounded transition">Sign Out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex border-b bg-white px-4 gap-1 overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition whitespace-nowrap ${tab === t.id ? 'border-[#8C1D40] text-[#8C1D40]' : 'border-transparent text-gray-600 hover:text-gray-900'}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<main className="max-w-6xl mx-auto p-4 sm:p-6">
|
||||
{tab === 'board' && <BoardTab />}
|
||||
{tab === 'events' && <EventsTab />}
|
||||
{tab === 'gallery' && <GalleryTab />}
|
||||
{tab === 'sponsors' && <SponsorsTab />}
|
||||
{tab === 'minutes' && <MinutesTab />}
|
||||
{tab === 'members' && <MembersTab />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
const Manage: React.FC = () => {
|
||||
const [token, setToken] = useState<string | null>(getToken);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [screen, setScreen] = useState<AuthScreen>('login');
|
||||
const [tempToken, setTempToken] = useState<string | null>(null);
|
||||
|
||||
// Detect /manage/reset-password?token=xxx
|
||||
const resetToken = new URLSearchParams(window.location.search).get('token');
|
||||
const isResetPage = window.location.pathname.includes('/reset-password') && !!resetToken;
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
api<User>('/auth/me').then(setUser).catch(() => {
|
||||
localStorage.removeItem('mcdi_token'); setToken(null);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const handleLogin = (t: string, u: User) => {
|
||||
localStorage.setItem('mcdi_token', t);
|
||||
setToken(t); setUser(u); setScreen('login'); setTempToken(null);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('mcdi_token');
|
||||
setToken(null); setUser(null); setScreen('login');
|
||||
};
|
||||
|
||||
if (isResetPage) return <ResetPassword token={resetToken!} onLogin={handleLogin} />;
|
||||
if (tempToken) return <ForceChangePassword tempToken={tempToken} onLogin={handleLogin} />;
|
||||
if (screen === 'forgot') return <ForgotPassword onBack={() => setScreen('login')} />;
|
||||
if (!token || !user) return <Login onLogin={handleLogin} onTempToken={t => { setTempToken(t); }} onForgot={() => setScreen('forgot')} />;
|
||||
return <Dashboard user={user} onLogout={handleLogout} />;
|
||||
};
|
||||
|
||||
export default Manage;
|
||||
|
|
@ -10,36 +10,6 @@ interface BoardMember {
|
|||
bio: string;
|
||||
}
|
||||
|
||||
const boardMembers: BoardMember[] = [
|
||||
{
|
||||
name: "Rita Brandborg",
|
||||
position: "President",
|
||||
email: "rita.brandborg@deafmissoula.org",
|
||||
image: "/headshots/rita.jpg",
|
||||
bio: "Meet Rita Brandborg, the proud mom of two tiny tornados (Levi and Josie) who keep her on her toes! When she's not wrangling her mini-me's or teaching Sign 102 at the University of Montana, you can find Rita snapping photos with her trusty camera or reeling in the big ones fly fishing. This Deaf momma is all about spreading joy, love, and a little bit of chaos wherever she goes! With a heart full of laughter and a mind full of stories, Rita is living life to the fullest - and loving every minute of it!"
|
||||
},
|
||||
{
|
||||
name: "Skyla Wilson",
|
||||
position: "Vice President",
|
||||
email: "skyla.wilson@deafmissoula.org",
|
||||
image: "/headshots/skyla.jpg",
|
||||
bio: "Meet Skyla Wilson, the river soul with a heart tuned to justice. She’s a fierce advocate for the Deaf community, always ready to stand up, speak out (in her own way), and make space for voices that often go unheard. When she’s not pushing for accessibility and equity, you’ll find her floating peacefully down a river, soaking in the calm before diving back into the work. Skyla blends quiet power with unstoppable purpose—equal parts grace, grit, and a deep love for her community."
|
||||
},
|
||||
{
|
||||
name: "Tessa Williams",
|
||||
position: "Secretary",
|
||||
email: "tessa.williams@deafmissoula.org",
|
||||
image: "/headshots/tessa.jpg",
|
||||
bio: "Meet Tessa Williams, the sparkplug of the University of Montana Social Worker's program. By day, she's a social work student with a heart of gold; by night, she's a sass-spewing machine who can take down anyone with her quick wit and sharp humor. When she's not advocating for Deaf rights or making her friends laugh, Tessa can be found sipping on a matcha latte with honey (her happy place). Don't mess with this tiny firecracker - she's got love for all, except maybe for those who can't keep up with her sass."
|
||||
},
|
||||
{
|
||||
name: "Aubz M",
|
||||
position: "Treasurer",
|
||||
email: "aubz.m@deafmissoula.org",
|
||||
image: "/headshots/aubz.jpg",
|
||||
bio: "Aubz M is a shining star at the local veterinary hospital, where they're paving their way to become a certified vet tech. This animal whisperer's heart beats for creatures great and small, but it also swells with love for self-care and coziness. When they're not snuggling Kanga (their adorable pup), Aubz can be found soaking up wellness vibes or cracking jokes that'll leave you giggling. With a quick wit and thoughtful spirit, this Deaf rockstar is spreading kindness and compassion wherever they go!"
|
||||
}
|
||||
];
|
||||
|
||||
const shuffleArray = (array: BoardMember[]) => {
|
||||
const shuffled = [...array];
|
||||
|
|
@ -60,7 +30,9 @@ const MeetBoard: React.FC = () => {
|
|||
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setShuffledMembers(shuffleArray(boardMembers));
|
||||
fetch('/api/board')
|
||||
.then(r => r.json())
|
||||
.then((members: BoardMember[]) => setShuffledMembers(shuffleArray(members)));
|
||||
}, []);
|
||||
|
||||
const togglePlay = () => {
|
||||
|
|
|
|||
|
|
@ -8,56 +8,19 @@ interface MinuteEntry {
|
|||
fileUrl: string;
|
||||
}
|
||||
|
||||
const minutesData: MinuteEntry[] = [
|
||||
{
|
||||
date: '04/12/2025',
|
||||
meetingType: 'Membership Meeting',
|
||||
location: 'The Break Coffee',
|
||||
fileUrl: '/minutes/minutes-04122025.pdf',
|
||||
},
|
||||
{
|
||||
date: '03/08/2025',
|
||||
meetingType: 'Board Meeting',
|
||||
location: 'The Break Espresso',
|
||||
fileUrl: '/minutes/minutes-03082025.pdf',
|
||||
},
|
||||
{
|
||||
date: '02/08/2025',
|
||||
meetingType: 'Board Meeting',
|
||||
location: 'Book Exchange - Liquid Planet',
|
||||
fileUrl: '/minutes/minutes-02082025.pdf',
|
||||
},
|
||||
{
|
||||
date: '01/11/2025',
|
||||
meetingType: 'Membership Meeting',
|
||||
location: 'Black Coffee Roasting Company',
|
||||
fileUrl: '/minutes/minutes-01112025.pdf',
|
||||
},
|
||||
{
|
||||
date: '10/17/2024',
|
||||
meetingType: 'Board Meeting',
|
||||
location: 'UC Market (University)',
|
||||
fileUrl: '/minutes/minutes-10172024.pdf',
|
||||
},
|
||||
{
|
||||
date: '09/14/2024',
|
||||
meetingType: 'Membership Meeting',
|
||||
location: 'Dog Wash Cafe',
|
||||
fileUrl: '/minutes/minutes-09142024.pdf',
|
||||
},
|
||||
{
|
||||
date: '08/06/2024',
|
||||
meetingType: 'Membership Meeting',
|
||||
location: 'The Break Espresso',
|
||||
fileUrl: '/minutes/minutes-08062024.pdf',
|
||||
},
|
||||
];
|
||||
|
||||
const Minutes: React.FC = () => {
|
||||
const [sortColumn, setSortColumn] = useState<keyof MinuteEntry>('date');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedMinutes, setSelectedMinutes] = useState<MinuteEntry | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [minutesData, setMinutesData] = useState<MinuteEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/minutes')
|
||||
.then(r => r.json())
|
||||
.then(setMinutesData);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
|
|
@ -72,15 +35,13 @@ const Minutes: React.FC = () => {
|
|||
|
||||
const sortedMinutes = [...minutesData].sort((a, b) => {
|
||||
if (sortColumn === 'date') {
|
||||
// Convert date strings to Date objects for proper comparison
|
||||
const dateA = new Date(a.date.split('/').map((part, i) => i === 0 ? part : i === 1 ? part : part).join('/'));
|
||||
const dateB = new Date(b.date.split('/').map((part, i) => i === 0 ? part : i === 1 ? part : part).join('/'));
|
||||
const dateA = new Date(a.date);
|
||||
const dateB = new Date(b.date);
|
||||
return sortDirection === 'asc' ? dateA.getTime() - dateB.getTime() : dateB.getTime() - dateA.getTime();
|
||||
} else {
|
||||
if (a[sortColumn] < b[sortColumn]) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (a[sortColumn] > b[sortColumn]) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
if (a[sortColumn] < b[sortColumn]) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (a[sortColumn] > b[sortColumn]) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const handleSort = (column: keyof MinuteEntry) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface SponsorCardProps {
|
||||
|
|
@ -103,37 +103,17 @@ interface SponsorInfo {
|
|||
|
||||
const Sponsors: React.FC = () => {
|
||||
const [selectedSponsor, setSelectedSponsor] = useState<string | null>(null);
|
||||
const [sponsorInfo, setSponsorInfo] = useState<Record<string, SponsorInfo>>({});
|
||||
|
||||
const sponsorInfo: Record<string, SponsorInfo> = {
|
||||
"Drum Coffee": {
|
||||
description: "Drum Coffee partnered with MCDi to provide ASL lessons on coffee-related signology, enhancing their ability to serve Deaf community members. This initiative promotes inclusivity and improves communication in their bustling cafe environment.",
|
||||
logoPath: "/sponsors/drum-coffee-missoula.png",
|
||||
websiteUrl: "https://drumcoffeeroasting.com/",
|
||||
facebookUrl: "https://m.facebook.com/drumcoffeemt",
|
||||
instagramUrl: "https://www.instagram.com/drumcoffee/"
|
||||
},
|
||||
"Imagine Nation Brewing": {
|
||||
description: "Imagine Nation Brewing hosted a vibrant fundraising event, donating proceeds to MCDi. Their generous support not only raised funds but also raised awareness about the Deaf community, making a significant impact on our mission.",
|
||||
logoPath: "/sponsors/imagine-nation-brewing.png",
|
||||
websiteUrl: "https://imaginenationbrewing.com/",
|
||||
facebookUrl: "https://m.facebook.com/ImagineNationBrewing",
|
||||
instagramUrl: "https://www.instagram.com/imaginenationbrewingco"
|
||||
},
|
||||
"GILD Brewing": {
|
||||
description: "GILD Brewing organized a community-focused fundraiser for MCDi, combining craft beer tasting with Deaf culture education. Their event not only raised funds but also fostered a deeper understanding of Deaf culture, making a significant impact on our mission.",
|
||||
logoPath: "/sponsors/gild-brewing.png",
|
||||
websiteUrl: "https://www.gildbrewing.com/",
|
||||
facebookUrl: "https://m.facebook.com/gildbrewing/",
|
||||
instagramUrl: "https://www.instagram.com/gildbrewing/"
|
||||
},
|
||||
"SBS Solar": {
|
||||
description: "SBS Solar supports MCDi's mission to serve the Deaf community in Missoula. Their commitment to renewable energy and community engagement aligns with our values of accessibility and sustainability.",
|
||||
logoPath: "/sponsors/sbs-solar.png",
|
||||
websiteUrl: "https://www.sbslink.com/",
|
||||
facebookUrl: "https://www.facebook.com/sbssolar",
|
||||
instagramUrl: "https://www.instagram.com/sbssolarmt/"
|
||||
},
|
||||
};
|
||||
useEffect(() => {
|
||||
fetch('/api/sponsors')
|
||||
.then(r => r.json())
|
||||
.then((sponsors: Array<{ name: string } & SponsorInfo>) => {
|
||||
const record: Record<string, SponsorInfo> = {};
|
||||
sponsors.forEach(s => { record[s.name] = s; });
|
||||
setSponsorInfo(record);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
|
|
|
|||
|
|
@ -748,9 +748,15 @@ select {
|
|||
.right-0 {
|
||||
right: 0px;
|
||||
}
|
||||
.right-1 {
|
||||
right: 0.25rem;
|
||||
}
|
||||
.right-2 {
|
||||
right: 0.5rem;
|
||||
}
|
||||
.top-1 {
|
||||
top: 0.25rem;
|
||||
}
|
||||
.top-2 {
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
|
@ -768,9 +774,18 @@ select {
|
|||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.mb-16 {
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.mb-3 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
|
@ -783,9 +798,18 @@ select {
|
|||
.ml-1 {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.mr-1 {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.mr-2 {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.mr-3 {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
.mt-0\.5 {
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
|
@ -837,6 +861,9 @@ select {
|
|||
.h-16 {
|
||||
height: 4rem;
|
||||
}
|
||||
.h-20 {
|
||||
height: 5rem;
|
||||
}
|
||||
.h-32 {
|
||||
height: 8rem;
|
||||
}
|
||||
|
|
@ -879,6 +906,12 @@ select {
|
|||
.w-12 {
|
||||
width: 3rem;
|
||||
}
|
||||
.w-16 {
|
||||
width: 4rem;
|
||||
}
|
||||
.w-20 {
|
||||
width: 5rem;
|
||||
}
|
||||
.w-32 {
|
||||
width: 8rem;
|
||||
}
|
||||
|
|
@ -897,21 +930,39 @@ select {
|
|||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.min-w-0 {
|
||||
min-width: 0px;
|
||||
}
|
||||
.max-w-4xl {
|
||||
max-width: 56rem;
|
||||
}
|
||||
.max-w-5xl {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.max-w-6xl {
|
||||
max-width: 72rem;
|
||||
}
|
||||
.max-w-7xl {
|
||||
max-width: 80rem;
|
||||
}
|
||||
.max-w-full {
|
||||
max-width: 100%;
|
||||
}
|
||||
.max-w-md {
|
||||
max-width: 28rem;
|
||||
}
|
||||
.max-w-sm {
|
||||
max-width: 24rem;
|
||||
}
|
||||
.max-w-xs {
|
||||
max-width: 20rem;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
|
@ -927,9 +978,16 @@ select {
|
|||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.cursor-grab {
|
||||
cursor: grab;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-none {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
|
|
@ -957,6 +1015,9 @@ select {
|
|||
.items-start {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.items-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -978,6 +1039,9 @@ select {
|
|||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
|
@ -1002,6 +1066,11 @@ select {
|
|||
margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
.space-y-8 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
|
|
@ -1024,9 +1093,15 @@ select {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.whitespace-pre-line {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
|
@ -1054,6 +1129,9 @@ select {
|
|||
.border-b-2 {
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
.border-t {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
.border-t-2 {
|
||||
border-top-width: 2px;
|
||||
}
|
||||
|
|
@ -1088,10 +1166,17 @@ select {
|
|||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(140 29 64 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-\[\#8C1D40\]\/5 {
|
||||
background-color: rgb(140 29 64 / 0.05);
|
||||
}
|
||||
.bg-black {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(0 0 0 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-blue-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
|
|
@ -1100,10 +1185,29 @@ select {
|
|||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-400 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-gray-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-green-100 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 252 231 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-red-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-white {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
.bg-white\/20 {
|
||||
background-color: rgb(255 255 255 / 0.2);
|
||||
}
|
||||
.bg-yellow-400 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(250 204 21 / var(--tw-bg-opacity, 1));
|
||||
|
|
@ -1156,14 +1260,37 @@ select {
|
|||
.p-6 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.p-8 {
|
||||
padding: 2rem;
|
||||
}
|
||||
.px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
.py-0\.5 {
|
||||
padding-top: 0.125rem;
|
||||
padding-bottom: 0.125rem;
|
||||
}
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.py-1\.5 {
|
||||
padding-top: 0.375rem;
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
.py-12 {
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
|
|
@ -1180,6 +1307,12 @@ select {
|
|||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
.pb-2 {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
|
@ -1231,6 +1364,9 @@ select {
|
|||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.leading-relaxed {
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
|
@ -1254,6 +1390,18 @@ select {
|
|||
--tw-text-opacity: 1;
|
||||
color: rgb(59 130 246 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-gray-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
|
||||
|
|
@ -1266,10 +1414,22 @@ select {
|
|||
--tw-text-opacity: 1;
|
||||
color: rgb(17 24 39 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-green-700 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(21 128 61 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-green-800 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(22 101 52 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-red-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-red-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(220 38 38 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-white {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
||||
|
|
@ -1288,6 +1448,12 @@ select {
|
|||
.accent-yellow-300 {
|
||||
accent-color: #fde047;
|
||||
}
|
||||
.opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
.opacity-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.opacity-80 {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
|
@ -1323,11 +1489,21 @@ select {
|
|||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-colors {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-opacity {
|
||||
transition-property: opacity;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
.transition-shadow {
|
||||
transition-property: box-shadow;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
|
@ -1640,11 +1816,35 @@ html {
|
|||
background-color: rgb(107 22 49 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-blue-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-100:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-50:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-500:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-red-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-white\/30:hover {
|
||||
background-color: rgb(255 255 255 / 0.3);
|
||||
}
|
||||
|
||||
.hover\:bg-yellow-300:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(253 224 71 / var(--tw-bg-opacity, 1));
|
||||
|
|
@ -1698,12 +1898,28 @@ html {
|
|||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
.hover\:opacity-100:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hover\:shadow-xl:hover {
|
||||
--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
|
||||
}
|
||||
|
||||
.active\:cursor-grabbing:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.disabled\:opacity-50:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.group:hover .group-hover\:opacity-100 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
|
||||
.sm\:mb-4 {
|
||||
|
|
@ -1734,6 +1950,10 @@ html {
|
|||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sm\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sm\:gap-8 {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
|
@ -1774,6 +1994,10 @@ html {
|
|||
|
||||
@media (min-width: 768px) {
|
||||
|
||||
.md\:col-span-2 {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.md\:mb-0 {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
|
@ -1826,6 +2050,10 @@ html {
|
|||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.md\:grid-cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.md\:gap-8 {
|
||||
gap: 2rem;
|
||||
}
|
||||
|
|
@ -1884,6 +2112,10 @@ html {
|
|||
width: 50%;
|
||||
}
|
||||
|
||||
.lg\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
|
@ -1892,6 +2124,10 @@ html {
|
|||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lg\:grid-cols-6 {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lg\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
|
|
|||
9
docker-compose.dev.yml
Normal file
9
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
services:
|
||||
app:
|
||||
networks:
|
||||
- internal
|
||||
|
||||
networks:
|
||||
caddy_network:
|
||||
name: caddy_network_dev
|
||||
driver: bridge
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
version: '3'
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
|
|
@ -7,17 +6,57 @@ services:
|
|||
args:
|
||||
CACHEBUST: ${CACHEBUST:-1}
|
||||
pull_policy: build
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "801:801"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- VERSION=1.11
|
||||
- VERSION=2.0
|
||||
env_file:
|
||||
- .env
|
||||
- stack.env
|
||||
volumes:
|
||||
- uploads-data:/app/uploads
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- caddy_network
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:801/api/events"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
mongodb:
|
||||
image: mongo:7.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
networks:
|
||||
- internal
|
||||
env_file:
|
||||
- stack.env
|
||||
environment:
|
||||
- MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}
|
||||
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}
|
||||
- MONGO_INITDB_DATABASE=mcdi
|
||||
command: ["--auth", "--bind_ip_all"]
|
||||
healthcheck:
|
||||
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017 --quiet
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
networks:
|
||||
caddy_network:
|
||||
external: true
|
||||
internal:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
name: mcdi-mongo-data
|
||||
uploads-data:
|
||||
name: mcdi-uploads-data
|
||||
|
|
|
|||
2070
server/package-lock.json
generated
Normal file
2070
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,29 +1,35 @@
|
|||
{
|
||||
"name": "mcdi-website-server",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"mongoose": "^7.6.3",
|
||||
"cors": "^2.8.5",
|
||||
"body-parser": "^1.20.2",
|
||||
"nodemailer": "^6.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/node": "^20.8.9",
|
||||
"@types/mongodb": "^4.0.7",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.2.2"
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon src/index.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"postinstall": "npm run build"
|
||||
"seed": "tsx src/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.43.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"jose": "^5.10.0",
|
||||
"mongoose": "^8.13.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.15.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
server/src/db.ts
Normal file
9
server/src/db.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import mongoose from 'mongoose';
|
||||
|
||||
export async function connectDB(): Promise<void> {
|
||||
const uri = process.env.MONGODB_URI;
|
||||
if (!uri) throw new Error('MONGODB_URI is not defined');
|
||||
|
||||
await mongoose.connect(uri);
|
||||
console.log('MongoDB connected');
|
||||
}
|
||||
|
|
@ -1,41 +1,51 @@
|
|||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import cors from 'cors';
|
||||
import bodyParser from 'body-parser';
|
||||
import sendEmail from './api/sendEmail';
|
||||
import path from 'path';
|
||||
import { connectDB } from './db';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import authRoutes from './routes/auth';
|
||||
import boardRoutes from './routes/board';
|
||||
import eventRoutes from './routes/events';
|
||||
import galleryRoutes from './routes/gallery';
|
||||
import sponsorRoutes from './routes/sponsors';
|
||||
import minuteRoutes from './routes/minutes';
|
||||
import memberRoutes from './routes/members';
|
||||
import emailRoutes from './routes/email';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 801;
|
||||
const port = process.env.PORT ?? 801;
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
app.use(express.static(path.join(__dirname, '../../client/build')));
|
||||
app.use(express.json());
|
||||
|
||||
app.post('/api/sendEmail', sendEmail);
|
||||
// Serve uploaded files
|
||||
app.use('/uploads', express.static(path.join(__dirname, '../../uploads')));
|
||||
|
||||
app.get('/api/calendar-events', (req, res) => {
|
||||
try {
|
||||
const calendarDataPath = path.join(__dirname, '../calendarData.json');
|
||||
if (fs.existsSync(calendarDataPath)) {
|
||||
const calendarData = fs.readFileSync(calendarDataPath, 'utf-8');
|
||||
const events = JSON.parse(calendarData);
|
||||
res.json(events);
|
||||
} else {
|
||||
// If file doesn't exist, return an empty array
|
||||
res.json([]);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Error reading calendar events:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch calendar events' });
|
||||
}
|
||||
// API routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/board', boardRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/sponsors', sponsorRoutes);
|
||||
app.use('/api/minutes', minuteRoutes);
|
||||
app.use('/api/members', memberRoutes);
|
||||
app.use('/api', emailRoutes);
|
||||
|
||||
// Serve built React app
|
||||
const clientBuild = path.join(__dirname, '../../client/build');
|
||||
app.use(express.static(clientBuild));
|
||||
app.use((_req, res) => {
|
||||
res.sendFile(path.join(clientBuild, 'index.html'));
|
||||
});
|
||||
|
||||
// Serve React app for all other routes
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../../client/build/index.html'));
|
||||
});
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
});
|
||||
connectDB()
|
||||
.then(() => {
|
||||
app.listen(port, () => console.log(`Server running on port ${port}`));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to connect to MongoDB:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
27
server/src/middleware/auth.ts
Normal file
27
server/src/middleware/auth.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { jwtVerify } from 'jose';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: string;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
const secret = () => new TextEncoder().encode(process.env.JWT_SECRET ?? 'fallback-dev-secret');
|
||||
|
||||
export async function requireAuth(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = header.slice(7);
|
||||
const { payload } = await jwtVerify(token, secret());
|
||||
req.userId = payload.userId as string;
|
||||
req.userRole = payload.role as string;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
24
server/src/middleware/errorHandler.ts
Normal file
24
server/src/middleware/errorHandler.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
export function errorHandler(
|
||||
err: unknown,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
if (err instanceof ZodError) {
|
||||
res.status(400).json({ error: 'Validation error', details: err.flatten() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
const status = (err as NodeJS.ErrnoException & { status?: number }).status ?? 500;
|
||||
const message = status < 500 ? err.message : 'Internal server error';
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({ error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
33
server/src/middleware/upload.ts
Normal file
33
server/src/middleware/upload.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const uploadsBase = path.join(__dirname, '../../../uploads');
|
||||
|
||||
function storageFor(subdir: string) {
|
||||
return multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
const dir = path.join(uploadsBase, subdir);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const unique = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
cb(null, unique + path.extname(file.originalname));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
|
||||
const allowed = /jpeg|jpg|png|gif|webp/;
|
||||
cb(null, allowed.test(path.extname(file.originalname).toLowerCase()) && allowed.test(file.mimetype));
|
||||
};
|
||||
|
||||
const pdfFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
|
||||
cb(null, file.mimetype === 'application/pdf' || path.extname(file.originalname).toLowerCase() === '.pdf');
|
||||
};
|
||||
|
||||
export const uploadHeadshot = multer({ storage: storageFor('headshots'), fileFilter: imageFilter, limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
export const uploadPhoto = multer({ storage: storageFor('photos'), fileFilter: imageFilter, limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
export const uploadLogo = multer({ storage: storageFor('sponsors'), fileFilter: imageFilter, limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
export const uploadMinutes = multer({ storage: storageFor('minutes'), fileFilter: pdfFilter, limits: { fileSize: 20 * 1024 * 1024 } });
|
||||
29
server/src/models/BoardMember.ts
Normal file
29
server/src/models/BoardMember.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface IBoardMember extends Document {
|
||||
name: string;
|
||||
position: string;
|
||||
email: string;
|
||||
image: string;
|
||||
bio: string;
|
||||
order: number;
|
||||
password?: string;
|
||||
mustChangePassword: boolean;
|
||||
resetToken?: string;
|
||||
resetTokenExpiry?: Date;
|
||||
}
|
||||
|
||||
const BoardMemberSchema = new Schema<IBoardMember>({
|
||||
name: { type: String, required: true, trim: true },
|
||||
position: { type: String, required: true, trim: true },
|
||||
email: { type: String, required: true, unique: true, trim: true, lowercase: true },
|
||||
image: { type: String, required: true },
|
||||
bio: { type: String, required: true },
|
||||
order: { type: Number, default: 0 },
|
||||
password: { type: String, select: false },
|
||||
mustChangePassword:{ type: Boolean, default: true },
|
||||
resetToken: { type: String, select: false },
|
||||
resetTokenExpiry: { type: Date, select: false },
|
||||
}, { timestamps: true });
|
||||
|
||||
export const BoardMember = mongoose.model<IBoardMember>('BoardMember', BoardMemberSchema);
|
||||
25
server/src/models/Event.ts
Normal file
25
server/src/models/Event.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface IEvent extends Document {
|
||||
date: string;
|
||||
title: string;
|
||||
description: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
pointOfContact: string;
|
||||
email: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
const EventSchema = new Schema<IEvent>({
|
||||
date: { type: String, required: true },
|
||||
title: { type: String, required: true, trim: true },
|
||||
description: { type: String, required: true, trim: true },
|
||||
startTime: { type: String, required: true },
|
||||
endTime: { type: String, required: true },
|
||||
pointOfContact: { type: String, required: true, trim: true },
|
||||
email: { type: String, required: true, trim: true, lowercase: true },
|
||||
address: { type: String, required: true, trim: true },
|
||||
}, { timestamps: true });
|
||||
|
||||
export const Event = mongoose.model<IEvent>('Event', EventSchema);
|
||||
15
server/src/models/GalleryItem.ts
Normal file
15
server/src/models/GalleryItem.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface IGalleryItem extends Document {
|
||||
imageUrl: string;
|
||||
alt: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const GalleryItemSchema = new Schema<IGalleryItem>({
|
||||
imageUrl: { type: String, required: true },
|
||||
alt: { type: String, required: true, trim: true },
|
||||
order: { type: Number, default: 0 },
|
||||
}, { timestamps: true });
|
||||
|
||||
export const GalleryItem = mongoose.model<IGalleryItem>('GalleryItem', GalleryItemSchema);
|
||||
27
server/src/models/Member.ts
Normal file
27
server/src/models/Member.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface IMember extends Document {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
membershipType: 'regular' | 'honorary' | 'board';
|
||||
status: 'active' | 'inactive';
|
||||
joinDate: Date;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
const MemberSchema = new Schema<IMember>({
|
||||
firstName: { type: String, required: true, trim: true },
|
||||
lastName: { type: String, required: true, trim: true },
|
||||
email: { type: String, required: true, unique: true, trim: true, lowercase: true },
|
||||
phone: { type: String },
|
||||
address: { type: String },
|
||||
membershipType: { type: String, enum: ['regular', 'honorary', 'board'], default: 'regular' },
|
||||
status: { type: String, enum: ['active', 'inactive'], default: 'active' },
|
||||
joinDate: { type: Date, required: true, default: Date.now },
|
||||
notes: { type: String },
|
||||
}, { timestamps: true });
|
||||
|
||||
export const Member = mongoose.model<IMember>('Member', MemberSchema);
|
||||
19
server/src/models/Minute.ts
Normal file
19
server/src/models/Minute.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface IMinute extends Document {
|
||||
date: string;
|
||||
meetingType: string;
|
||||
location: string;
|
||||
fileUrl: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const MinuteSchema = new Schema<IMinute>({
|
||||
date: { type: String, required: true },
|
||||
meetingType: { type: String, required: true, trim: true },
|
||||
location: { type: String, required: true, trim: true },
|
||||
fileUrl: { type: String, required: true },
|
||||
order: { type: Number, default: 0 },
|
||||
}, { timestamps: true });
|
||||
|
||||
export const Minute = mongoose.model<IMinute>('Minute', MinuteSchema);
|
||||
23
server/src/models/Sponsor.ts
Normal file
23
server/src/models/Sponsor.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface ISponsor extends Document {
|
||||
name: string;
|
||||
logoPath: string;
|
||||
description: string;
|
||||
websiteUrl?: string;
|
||||
facebookUrl?: string;
|
||||
instagramUrl?: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const SponsorSchema = new Schema<ISponsor>({
|
||||
name: { type: String, required: true, trim: true },
|
||||
logoPath: { type: String, required: true },
|
||||
description: { type: String, required: true, trim: true },
|
||||
websiteUrl: { type: String },
|
||||
facebookUrl: { type: String },
|
||||
instagramUrl: { type: String },
|
||||
order: { type: Number, default: 0 },
|
||||
}, { timestamps: true });
|
||||
|
||||
export const Sponsor = mongoose.model<ISponsor>('Sponsor', SponsorSchema);
|
||||
20
server/src/models/User.ts
Normal file
20
server/src/models/User.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface IUser extends Document {
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'admin' | 'board';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const UserSchema = new Schema<IUser>({
|
||||
username: { type: String, required: true, unique: true, trim: true },
|
||||
password: { type: String, required: true },
|
||||
name: { type: String, required: true, trim: true },
|
||||
email: { type: String, required: true, unique: true, trim: true, lowercase: true },
|
||||
role: { type: String, enum: ['admin', 'board'], default: 'board' },
|
||||
}, { timestamps: true });
|
||||
|
||||
export const User = mongoose.model<IUser>('User', UserSchema);
|
||||
162
server/src/routes/auth.ts
Normal file
162
server/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { Router } from 'express';
|
||||
import { SignJWT } from 'jose';
|
||||
import * as argon2 from 'argon2';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'crypto';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { User } from '../models/User';
|
||||
import { BoardMember } from '../models/BoardMember';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const jwtSecret = () => new TextEncoder().encode(process.env.JWT_SECRET ?? 'fallback-dev-secret');
|
||||
const appUrl = () => process.env.APP_URL ?? 'http://localhost:801';
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000,
|
||||
limit: 5,
|
||||
standardHeaders: 'draft-8',
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many login attempts. Please wait 5 minutes before trying again.' },
|
||||
});
|
||||
|
||||
async function makeToken(payload: Record<string, unknown>) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setExpirationTime('7d')
|
||||
.sign(jwtSecret());
|
||||
}
|
||||
|
||||
async function makeShortToken(payload: Record<string, unknown>) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setExpirationTime('1h')
|
||||
.sign(jwtSecret());
|
||||
}
|
||||
|
||||
async function sendMail(to: string, subject: string, html: string) {
|
||||
if (!process.env.GOOGLE_EMAIL || !process.env.GOOGLE_APP_PASSWORD) return;
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: { user: process.env.GOOGLE_EMAIL, pass: process.env.GOOGLE_APP_PASSWORD },
|
||||
});
|
||||
await transporter.sendMail({ from: process.env.GOOGLE_EMAIL, to, subject, html });
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
const { email, password } = z.object({ email: z.string().email(), password: z.string().min(1) }).parse(req.body);
|
||||
|
||||
// Check board member first
|
||||
const member = await BoardMember.findOne({ email }).select('+password +mustChangePassword +resetToken +resetTokenExpiry');
|
||||
if (member) {
|
||||
if (!member.password) {
|
||||
res.status(401).json({ error: 'No password set. Use "Forgot Password" to set your password.' });
|
||||
return;
|
||||
}
|
||||
const valid = await argon2.verify(member.password, password);
|
||||
if (!valid) { res.status(401).json({ error: 'Invalid email or password.' }); return; }
|
||||
|
||||
if (member.mustChangePassword) {
|
||||
const tempToken = await makeShortToken({ userId: member.id, role: 'board', mustChange: true });
|
||||
res.json({ mustChangePassword: true, tempToken });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await makeToken({ userId: member.id, role: 'board' });
|
||||
res.json({ token, user: { id: member.id, name: member.name, email: member.email, role: 'board' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to admin user (login by email)
|
||||
const admin = await User.findOne({ email });
|
||||
if (!admin) { res.status(401).json({ error: 'Invalid email or password.' }); return; }
|
||||
const valid = await argon2.verify(admin.password, password);
|
||||
if (!valid) { res.status(401).json({ error: 'Invalid email or password.' }); return; }
|
||||
|
||||
const token = await makeToken({ userId: admin.id, role: admin.role });
|
||||
res.json({ token, user: { id: admin.id, name: admin.name, email: admin.email, role: admin.role } });
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', requireAuth, async (req: AuthRequest, res) => {
|
||||
const member = await BoardMember.findById(req.userId);
|
||||
if (member) { res.json({ id: member.id, name: member.name, email: member.email, role: 'board' }); return; }
|
||||
const admin = await User.findById(req.userId).select('-password');
|
||||
if (!admin) { res.status(404).json({ error: 'User not found' }); return; }
|
||||
res.json(admin);
|
||||
});
|
||||
|
||||
// POST /api/auth/change-password (forced change on first login)
|
||||
router.post('/change-password', requireAuth, async (req: AuthRequest, res) => {
|
||||
const { password, confirmPassword } = z.object({
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine(d => d.password === d.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'] })
|
||||
.parse(req.body);
|
||||
|
||||
const member = await BoardMember.findById(req.userId);
|
||||
if (!member) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
|
||||
member.password = await argon2.hash(password);
|
||||
member.mustChangePassword = false;
|
||||
await member.save();
|
||||
|
||||
const token = await makeToken({ userId: member.id, role: 'board' });
|
||||
res.json({ token, user: { id: member.id, name: member.name, email: member.email, role: 'board' } });
|
||||
});
|
||||
|
||||
// POST /api/auth/forgot-password
|
||||
router.post('/forgot-password', loginLimiter, async (req, res) => {
|
||||
const { email } = z.object({ email: z.string().email() }).parse(req.body);
|
||||
|
||||
const member = await BoardMember.findOne({ email }).select('+resetToken +resetTokenExpiry');
|
||||
if (member) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
member.resetToken = token;
|
||||
member.resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
await member.save();
|
||||
|
||||
const link = `${appUrl()}/manage/reset-password?token=${token}`;
|
||||
await sendMail(email, 'MCDi Management — Reset Your Password', `
|
||||
<p>Hi ${member.name},</p>
|
||||
<p>Click the link below to set your MCDi Management password. This link expires in 1 hour.</p>
|
||||
<p><a href="${link}">${link}</a></p>
|
||||
<p>If you did not request this, you can ignore this email.</p>
|
||||
`).catch(err => console.error('Email send failed:', err));
|
||||
}
|
||||
|
||||
// Always return 200 to avoid revealing whether email exists
|
||||
res.json({ message: 'If that email is registered, a reset link has been sent.' });
|
||||
});
|
||||
|
||||
// POST /api/auth/reset-password
|
||||
router.post('/reset-password', async (req, res) => {
|
||||
const { token, password, confirmPassword } = z.object({
|
||||
token: z.string().min(1),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine(d => d.password === d.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'] })
|
||||
.parse(req.body);
|
||||
|
||||
const member = await BoardMember.findOne({
|
||||
resetToken: token,
|
||||
resetTokenExpiry: { $gt: new Date() },
|
||||
}).select('+resetToken +resetTokenExpiry');
|
||||
|
||||
if (!member) { res.status(400).json({ error: 'Invalid or expired reset link.' }); return; }
|
||||
|
||||
member.password = await argon2.hash(password);
|
||||
member.mustChangePassword = false;
|
||||
member.resetToken = undefined;
|
||||
member.resetTokenExpiry = undefined;
|
||||
await member.save();
|
||||
|
||||
const jwtToken = await makeToken({ userId: member.id, role: 'board' });
|
||||
res.json({ token: jwtToken, user: { id: member.id, name: member.name, email: member.email, role: 'board' } });
|
||||
});
|
||||
|
||||
export { sendMail };
|
||||
export default router;
|
||||
70
server/src/routes/board.ts
Normal file
70
server/src/routes/board.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { BoardMember } from '../models/BoardMember';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { uploadHeadshot } from '../middleware/upload';
|
||||
import { sendMail } from './auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const boardSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
position: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
bio: z.string().min(1),
|
||||
order: z.coerce.number().optional(),
|
||||
image: z.string().optional(),
|
||||
});
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
const members = await BoardMember.find().sort({ order: 1, createdAt: 1 });
|
||||
res.json(members);
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, uploadHeadshot.single('image'), async (req, res) => {
|
||||
const data = boardSchema.parse(req.body);
|
||||
const image = req.file ? `/uploads/headshots/${req.file.filename}` : (data.image ?? '');
|
||||
const member = await BoardMember.create({ ...data, image, mustChangePassword: true });
|
||||
|
||||
// Send welcome email (best-effort)
|
||||
const token = randomBytes(32).toString('hex');
|
||||
member.resetToken = token;
|
||||
member.resetTokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days for welcome
|
||||
await member.save();
|
||||
|
||||
const appUrl = process.env.APP_URL ?? 'http://localhost:801';
|
||||
const link = `${appUrl}/manage/reset-password?token=${token}`;
|
||||
sendMail(member.email, 'Welcome to MCDi Management', `
|
||||
<p>Hi ${member.name},</p>
|
||||
<p>You've been added to the MCDi Management system. Click the link below to set your password and get started.</p>
|
||||
<p><a href="${link}">${link}</a></p>
|
||||
<p>This link expires in 7 days.</p>
|
||||
`).catch(err => console.error('Welcome email failed:', err));
|
||||
|
||||
res.status(201).json(member);
|
||||
});
|
||||
|
||||
router.put('/:id', requireAuth, uploadHeadshot.single('image'), async (req, res) => {
|
||||
const data = boardSchema.partial().parse(req.body);
|
||||
const update: Record<string, unknown> = { ...data };
|
||||
if (req.file) update.image = `/uploads/headshots/${req.file.filename}`;
|
||||
|
||||
const member = await BoardMember.findByIdAndUpdate(req.params.id, update, { new: true });
|
||||
if (!member) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json(member);
|
||||
});
|
||||
|
||||
// PATCH /api/board/reorder — body: { ids: [id1, id2, ...] } in desired order
|
||||
router.patch('/reorder', requireAuth, async (req, res) => {
|
||||
const { ids } = z.object({ ids: z.array(z.string()) }).parse(req.body);
|
||||
await Promise.all(ids.map((id, index) => BoardMember.findByIdAndUpdate(id, { order: index })));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAuth, async (req, res) => {
|
||||
await BoardMember.findByIdAndDelete(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export default router;
|
||||
35
server/src/routes/email.ts
Normal file
35
server/src/routes/email.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const emailSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
message: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/sendEmail', async (req, res) => {
|
||||
const { name, email, message } = emailSchema.parse(req.body);
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.GOOGLE_EMAIL,
|
||||
pass: process.env.GOOGLE_APP_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.GOOGLE_EMAIL,
|
||||
to: process.env.CONTACT_EMAIL ?? process.env.GOOGLE_EMAIL,
|
||||
subject: `MCDi Contact Form: ${name}`,
|
||||
text: `From: ${name} <${email}>\n\n${message}`,
|
||||
replyTo: email,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
42
server/src/routes/events.ts
Normal file
42
server/src/routes/events.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { Event } from '../models/Event';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const eventSchema = z.object({
|
||||
date: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
startTime: z.string().min(1),
|
||||
endTime: z.string().min(1),
|
||||
pointOfContact: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
address: z.string().min(1),
|
||||
});
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
const events = await Event.find().sort({ date: 1 });
|
||||
res.json(events);
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
const data = eventSchema.parse(req.body);
|
||||
const event = await Event.create(data);
|
||||
res.status(201).json(event);
|
||||
});
|
||||
|
||||
router.put('/:id', requireAuth, async (req, res) => {
|
||||
const data = eventSchema.partial().parse(req.body);
|
||||
const event = await Event.findByIdAndUpdate(req.params.id, data, { new: true });
|
||||
if (!event) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json(event);
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAuth, async (req, res) => {
|
||||
await Event.findByIdAndDelete(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export default router;
|
||||
37
server/src/routes/gallery.ts
Normal file
37
server/src/routes/gallery.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { GalleryItem } from '../models/GalleryItem';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { uploadPhoto } from '../middleware/upload';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
const items = await GalleryItem.find().sort({ order: 1, createdAt: 1 });
|
||||
res.json(items);
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, uploadPhoto.single('image'), async (req, res) => {
|
||||
const schema = z.object({ alt: z.string().min(1), order: z.coerce.number().optional() });
|
||||
const data = schema.parse(req.body);
|
||||
const imageUrl = req.file
|
||||
? `/uploads/photos/${req.file.filename}`
|
||||
: z.string().min(1).parse(req.body.imageUrl);
|
||||
const item = await GalleryItem.create({ ...data, imageUrl });
|
||||
res.status(201).json(item);
|
||||
});
|
||||
|
||||
router.put('/:id', requireAuth, async (req, res) => {
|
||||
const schema = z.object({ alt: z.string().optional(), order: z.coerce.number().optional() });
|
||||
const data = schema.parse(req.body);
|
||||
const item = await GalleryItem.findByIdAndUpdate(req.params.id, data, { new: true });
|
||||
if (!item) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json(item);
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAuth, async (req, res) => {
|
||||
await GalleryItem.findByIdAndDelete(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export default router;
|
||||
43
server/src/routes/members.ts
Normal file
43
server/src/routes/members.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { Member } from '../models/Member';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const memberSchema = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
membershipType: z.enum(['regular', 'honorary', 'board']).optional(),
|
||||
status: z.enum(['active', 'inactive']).optional(),
|
||||
joinDate: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
router.get('/', requireAuth, async (_req, res) => {
|
||||
const members = await Member.find().sort({ lastName: 1, firstName: 1 });
|
||||
res.json(members);
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
const data = memberSchema.parse(req.body);
|
||||
const member = await Member.create(data);
|
||||
res.status(201).json(member);
|
||||
});
|
||||
|
||||
router.put('/:id', requireAuth, async (req, res) => {
|
||||
const data = memberSchema.partial().parse(req.body);
|
||||
const member = await Member.findByIdAndUpdate(req.params.id, data, { new: true });
|
||||
if (!member) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json(member);
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAuth, async (req, res) => {
|
||||
await Member.findByIdAndDelete(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export default router;
|
||||
43
server/src/routes/minutes.ts
Normal file
43
server/src/routes/minutes.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { Minute } from '../models/Minute';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { uploadMinutes } from '../middleware/upload';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
const minutes = await Minute.find().sort({ order: 1, createdAt: -1 });
|
||||
res.json(minutes);
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, uploadMinutes.single('file'), async (req, res) => {
|
||||
const schema = z.object({
|
||||
date: z.string().min(1),
|
||||
meetingType: z.string().min(1),
|
||||
location: z.string().min(1),
|
||||
});
|
||||
const data = schema.parse(req.body);
|
||||
const fileUrl = req.file
|
||||
? `/uploads/minutes/${req.file.filename}`
|
||||
: z.string().min(1).parse(req.body.fileUrl);
|
||||
|
||||
// Insert at top (order 0), shift everything else down
|
||||
await Minute.updateMany({}, { $inc: { order: 1 } });
|
||||
const minute = await Minute.create({ ...data, fileUrl, order: 0 });
|
||||
res.status(201).json(minute);
|
||||
});
|
||||
|
||||
// PATCH /api/minutes/reorder — body: { ids: [id1, id2, ...] } in desired order
|
||||
router.patch('/reorder', requireAuth, async (req, res) => {
|
||||
const { ids } = z.object({ ids: z.array(z.string()) }).parse(req.body);
|
||||
await Promise.all(ids.map((id, index) => Minute.findByIdAndUpdate(id, { order: index })));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAuth, async (req, res) => {
|
||||
await Minute.findByIdAndDelete(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export default router;
|
||||
46
server/src/routes/sponsors.ts
Normal file
46
server/src/routes/sponsors.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { Sponsor } from '../models/Sponsor';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { uploadLogo } from '../middleware/upload';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const sponsorSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
websiteUrl: z.string().url().optional().or(z.literal('')),
|
||||
facebookUrl: z.string().url().optional().or(z.literal('')),
|
||||
instagramUrl: z.string().url().optional().or(z.literal('')),
|
||||
order: z.coerce.number().optional(),
|
||||
logoPath: z.string().optional(),
|
||||
});
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
const sponsors = await Sponsor.find().sort({ order: 1, createdAt: 1 });
|
||||
res.json(sponsors);
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, uploadLogo.single('logo'), async (req, res) => {
|
||||
const data = sponsorSchema.parse(req.body);
|
||||
const logoPath = req.file ? `/uploads/sponsors/${req.file.filename}` : (data.logoPath ?? '');
|
||||
const sponsor = await Sponsor.create({ ...data, logoPath });
|
||||
res.status(201).json(sponsor);
|
||||
});
|
||||
|
||||
router.put('/:id', requireAuth, uploadLogo.single('logo'), async (req, res) => {
|
||||
const data = sponsorSchema.partial().parse(req.body);
|
||||
const update: Record<string, unknown> = { ...data };
|
||||
if (req.file) update.logoPath = `/uploads/sponsors/${req.file.filename}`;
|
||||
|
||||
const sponsor = await Sponsor.findByIdAndUpdate(req.params.id, update, { new: true });
|
||||
if (!sponsor) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.json(sponsor);
|
||||
});
|
||||
|
||||
router.delete('/:id', requireAuth, async (req, res) => {
|
||||
await Sponsor.findByIdAndDelete(req.params.id);
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
export default router;
|
||||
95
server/src/seed.ts
Normal file
95
server/src/seed.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import 'dotenv/config';
|
||||
import * as argon2 from 'argon2';
|
||||
import { connectDB } from './db';
|
||||
import { User } from './models/User';
|
||||
import { BoardMember } from './models/BoardMember';
|
||||
import { Event } from './models/Event';
|
||||
import { GalleryItem } from './models/GalleryItem';
|
||||
import { Sponsor } from './models/Sponsor';
|
||||
import { Minute } from './models/Minute';
|
||||
|
||||
async function seed() {
|
||||
await connectDB();
|
||||
|
||||
// Admin user
|
||||
const existing = await User.findOne({ username: 'admin' });
|
||||
if (!existing) {
|
||||
const password = await argon2.hash(process.env.ADMIN_PASSWORD ?? 'changeme123!');
|
||||
await User.create({ username: 'admin', password, name: 'MCDi Admin', email: 'admin@deafmissoula.org', role: 'admin' });
|
||||
console.log('Created admin user');
|
||||
}
|
||||
|
||||
// Board members
|
||||
const boardCount = await BoardMember.countDocuments();
|
||||
if (boardCount === 0) {
|
||||
await BoardMember.insertMany([
|
||||
{ name: 'Rita Brandborg', position: 'President', email: 'rita.brandborg@deafmissoula.org', image: '/headshots/rita.jpg', bio: "Meet Rita Brandborg, the proud mom of two tiny tornados (Levi and Josie) who keep her on her toes! When she's not wrangling her mini-me's or teaching Sign 102 at the University of Montana, you can find Rita snapping photos with her trusty camera or reeling in the big ones fly fishing. This Deaf momma is all about spreading joy, love, and a little bit of chaos wherever she goes! With a heart full of laughter and a mind full of stories, Rita is living life to the fullest - and loving every minute of it!", order: 0 },
|
||||
{ name: 'Skyla Wilson', position: 'Vice President', email: 'skyla.wilson@deafmissoula.org', image: '/headshots/skyla.jpg', bio: "Meet Skyla Wilson, the river soul with a heart tuned to justice. She's a fierce advocate for the Deaf community, always ready to stand up, speak out (in her own way), and make space for voices that often go unheard. When she's not pushing for accessibility and equity, you'll find her floating peacefully down a river, soaking in the calm before diving back into the work. Skyla blends quiet power with unstoppable purpose—equal parts grace, grit, and a deep love for her community.", order: 1 },
|
||||
{ name: 'Tessa Williams', position: 'Secretary', email: 'tessa.williams@deafmissoula.org', image: '/headshots/tessa.jpg', bio: "Meet Tessa Williams, the sparkplug of the University of Montana Social Worker's program. By day, she's a social work student with a heart of gold; by night, she's a sass-spewing machine who can take down anyone with her quick wit and sharp humor. When she's not advocating for Deaf rights or making her friends laugh, Tessa can be found sipping on a matcha latte with honey (her happy place). Don't mess with this tiny firecracker - she's got love for all, except maybe for those who can't keep up with her sass.", order: 2 },
|
||||
{ name: 'Aubz M', position: 'Treasurer', email: 'aubz.m@deafmissoula.org', image: '/headshots/aubz.jpg', bio: "Aubz M is a shining star at the local veterinary hospital, where they're paving their way to become a certified vet tech. This animal whisperer's heart beats for creatures great and small, but it also swells with love for self-care and coziness. When they're not snuggling Kanga (their adorable pup), Aubz can be found soaking up wellness vibes or cracking jokes that'll leave you giggling. With a quick wit and thoughtful spirit, this Deaf rockstar is spreading kindness and compassion wherever they go!", order: 3 },
|
||||
]);
|
||||
console.log('Seeded board members');
|
||||
}
|
||||
|
||||
// Events
|
||||
const eventCount = await Event.countDocuments();
|
||||
if (eventCount === 0) {
|
||||
await Event.insertMany([
|
||||
{ date: '2026-03-14', title: 'MCDi Board Meeting', description: 'Board Meeting at Funk It Coffee and Thrift', startTime: '10:00', endTime: '11:00', pointOfContact: 'Tessa Williams', email: 'tessa.williams@deafmissoula.org', address: '314 N 1st St. West, Missoula MT 59802' },
|
||||
{ date: '2026-06-13', title: 'MCDi Board Meeting', description: 'Board Meeting', startTime: '09:00', endTime: '10:00', pointOfContact: 'Tessa Williams', email: 'tessa.williams@deafmissoula.org', address: 'Hybrid' },
|
||||
{ date: '2026-09-12', title: 'MCDi Board Meeting', description: 'Board Meeting', startTime: '09:00', endTime: '10:00', pointOfContact: 'Tessa Williams', email: 'tessa.williams@deafmissoula.org', address: 'TBD' },
|
||||
{ date: '2026-09-12', title: 'MCDi Annual Membership Meeting', description: 'Annual Membership Meeting', startTime: '10:00', endTime: '11:00', pointOfContact: 'Tessa Williams', email: 'tessa.williams@deafmissoula.org', address: 'TBD' },
|
||||
]);
|
||||
console.log('Seeded events');
|
||||
}
|
||||
|
||||
// Gallery
|
||||
const galleryCount = await GalleryItem.countDocuments();
|
||||
if (galleryCount === 0) {
|
||||
const photoPaths = [
|
||||
...Array.from({ length: 10 }, (_, i) => `/photos/${String(i + 1).padStart(4, '0')}.jpg`),
|
||||
'/photos/1.jpg', '/photos/2.jpg', '/photos/3.jpg',
|
||||
'/photos/4.jpg', '/photos/5.jpg', '/photos/6.jpg',
|
||||
'/photos/IMG_1615(1).jpg',
|
||||
];
|
||||
const items = photoPaths.map((imageUrl, i) => ({
|
||||
imageUrl,
|
||||
alt: 'MCDi community event photo',
|
||||
order: i,
|
||||
}));
|
||||
await GalleryItem.insertMany(items);
|
||||
console.log('Seeded gallery');
|
||||
}
|
||||
|
||||
// Sponsors
|
||||
const sponsorCount = await Sponsor.countDocuments();
|
||||
if (sponsorCount === 0) {
|
||||
await Sponsor.insertMany([
|
||||
{ name: 'Drum Coffee', logoPath: '/sponsors/drum-coffee-missoula.png', description: 'Drum Coffee partnered with MCDi to provide ASL lessons on coffee-related signology, enhancing their ability to serve Deaf community members. This initiative promotes inclusivity and improves communication in their bustling cafe environment.', websiteUrl: 'https://drumcoffeeroasting.com/', facebookUrl: 'https://m.facebook.com/drumcoffeemt', instagramUrl: 'https://www.instagram.com/drumcoffee/', order: 0 },
|
||||
{ name: 'Imagine Nation Brewing', logoPath: '/sponsors/imagine-nation-brewing.png', description: 'Imagine Nation Brewing hosted a vibrant fundraising event, donating proceeds to MCDi. Their generous support not only raised funds but also raised awareness about the Deaf community, making a significant impact on our mission.', websiteUrl: 'https://imaginenationbrewing.com/', facebookUrl: 'https://m.facebook.com/ImagineNationBrewing', instagramUrl: 'https://www.instagram.com/imaginenationbrewingco', order: 1 },
|
||||
{ name: 'GILD Brewing', logoPath: '/sponsors/gild-brewing.png', description: 'GILD Brewing organized a community-focused fundraiser for MCDi, combining craft beer tasting with Deaf culture education. Their event not only raised funds but also fostered a deeper understanding of Deaf culture, making a significant impact on our mission.', websiteUrl: 'https://www.gildbrewing.com/', facebookUrl: 'https://m.facebook.com/gildbrewing/', instagramUrl: 'https://www.instagram.com/gildbrewing/', order: 2 },
|
||||
{ name: 'SBS Solar', logoPath: '/sponsors/sbs-solar.png', description: "SBS Solar supports MCDi's mission to serve the Deaf community in Missoula. Their commitment to renewable energy and community engagement aligns with our values of accessibility and sustainability.", websiteUrl: 'https://www.sbslink.com/', facebookUrl: 'https://www.facebook.com/sbssolar', instagramUrl: 'https://www.instagram.com/sbssolarmt/', order: 3 },
|
||||
]);
|
||||
console.log('Seeded sponsors');
|
||||
}
|
||||
|
||||
// Minutes
|
||||
const minuteCount = await Minute.countDocuments();
|
||||
if (minuteCount === 0) {
|
||||
await Minute.insertMany([
|
||||
{ date: '04/12/2025', meetingType: 'Membership Meeting', location: 'The Break Coffee', fileUrl: '/minutes/minutes-04122025.pdf', order: 0 },
|
||||
{ date: '03/08/2025', meetingType: 'Board Meeting', location: 'The Break Espresso', fileUrl: '/minutes/minutes-03082025.pdf', order: 1 },
|
||||
{ date: '02/08/2025', meetingType: 'Board Meeting', location: 'Book Exchange - Liquid Planet', fileUrl: '/minutes/minutes-02082025.pdf', order: 2 },
|
||||
{ date: '01/11/2025', meetingType: 'Membership Meeting', location: 'Black Coffee Roasting Company', fileUrl: '/minutes/minutes-01112025.pdf', order: 3 },
|
||||
{ date: '10/17/2024', meetingType: 'Board Meeting', location: 'UC Market (University)', fileUrl: '/minutes/minutes-10172024.pdf', order: 4 },
|
||||
{ date: '09/14/2024', meetingType: 'Membership Meeting', location: 'Dog Wash Cafe', fileUrl: '/minutes/minutes-09142024.pdf', order: 5 },
|
||||
{ date: '08/06/2024', meetingType: 'Membership Meeting', location: 'The Break Espresso', fileUrl: '/minutes/minutes-08062024.pdf', order: 6 },
|
||||
]);
|
||||
console.log('Seeded minutes');
|
||||
}
|
||||
|
||||
console.log('Seed complete');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seed().catch((err) => { console.error(err); process.exit(1); });
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
14
stack.env
14
stack.env
|
|
@ -1 +1,15 @@
|
|||
CACHEBUST=1736003093
|
||||
|
||||
# MongoDB
|
||||
MONGO_USER=mcdi
|
||||
MONGO_PASSWORD=changeme-set-a-strong-password
|
||||
MONGODB_URI=mongodb://mcdi:changeme-set-a-strong-password@mongodb:27017/mcdi?authSource=admin
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=changeme-set-a-long-random-secret
|
||||
|
||||
# Admin dashboard default password (changed on first login recommended)
|
||||
ADMIN_PASSWORD=changeme123!
|
||||
|
||||
# Public-facing URL for password reset emails
|
||||
APP_URL=https://deafmissoula.org
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue