v2: MongoDB backend + /manage dashboard
- Replace Express 4 backend with Express 5 + Mongoose 8 + TypeScript 5.8 - Add MongoDB 8.0 container via docker-compose - Add JWT auth (jose), argon2 password hashing, zod validation, multer uploads - Models: BoardMember, Event, GalleryItem, Sponsor, Minute, Member, User - Full CRUD REST API for all content types - Seed script to populate MongoDB from hardcoded data - Wire all frontend components to fetch from API (no JSX changes) - Add /manage dashboard: login + tabs for Board, Events, Gallery, Sponsors, Minutes, Members - Uploads volume for persistent file storage across deploys
This commit is contained in:
parent
addd4858ae
commit
ef8d281b2b
34 changed files with 1478 additions and 281 deletions
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 (
|
||||
|
|
|
|||
590
client/src/components/Manage.tsx
Normal file
590
client/src/components/Manage.tsx
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface User { id: string; username: string; name: 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; }
|
||||
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';
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
|
||||
// ── Login screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
const Login: React.FC<{ onLogin: (token: string, user: User) => void }> = ({ onLogin }) => {
|
||||
const [username, setUsername] = 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 = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}).then(r => { if (!r.ok) throw new Error('Invalid credentials'); return r.json(); });
|
||||
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-6 text-center">MCDi Management</h1>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<input className="w-full border rounded px-3 py-2" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
|
||||
<input className="w-full border rounded px-3 py-2" 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Shared UI ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const Btn: React.FC<{ onClick?: () => void; type?: 'button'|'submit'; color?: string; disabled?: boolean; children: React.ReactNode }> = ({ onClick, type = 'button', color = 'bg-[#8C1D40] hover:bg-[#6B1631]', disabled, children }) => (
|
||||
<button type={type} onClick={onClick} disabled={disabled} className={`${color} text-white 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 ?? ''}`} />
|
||||
);
|
||||
|
||||
// ── 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 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 token = getToken();
|
||||
const url = editing._id ? `/api/board/${editing._id}` : '/api/board';
|
||||
await fetch(url, { method: editing._id ? 'PUT' : 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });
|
||||
setEditing(null); setImageFile(null); load();
|
||||
};
|
||||
|
||||
const del = async (id: string) => {
|
||||
if (!window.confirm('Remove this board member?')) return;
|
||||
await api(`/board/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-[#8C1D40]">Board Members</h2>
|
||||
<Btn onClick={() => setEditing({ name:'', position:'', email:'', image:'', bio:'', order:0 })}>+ 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"><Input required type="email" value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} /></Field>
|
||||
<Field label="Display Order"><Input type="number" value={editing.order ?? 0} onChange={e => setEditing(p => ({ ...p, order: +e.target.value }))} /></Field>
|
||||
<Field label="Photo (upload new or keep existing)">
|
||||
<Input type="file" accept="image/*" onChange={e => setImageFile(e.target.files?.[0] ?? null)} />
|
||||
{editing.image && !imageFile && <p className="text-xs text-gray-500 mt-1">Current: {editing.image}</p>}
|
||||
</Field>
|
||||
<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</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 => (
|
||||
<div key={m._id} className="flex items-center gap-3 bg-white border rounded p-3">
|
||||
<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}</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 load = useCallback(() => api<GalleryItem[]>('/gallery').then(setItems), []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!imageFile) return;
|
||||
const form = new FormData();
|
||||
form.append('image', imageFile);
|
||||
form.append('alt', alt);
|
||||
const token = getToken();
|
||||
await fetch('/api/gallery', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });
|
||||
setImageFile(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">
|
||||
<Field label="Photo"><Input required type="file" accept="image/*" onChange={e => setImageFile(e.target.files?.[0] ?? null)} /></Field>
|
||||
<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 token = getToken();
|
||||
const url = editing._id ? `/api/sponsors/${editing._id}` : '/api/sponsors';
|
||||
await fetch(url, { method: editing._id ? 'PUT' : 'POST', headers: { Authorization: `Bearer ${token}` }, 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:0 })}>+ 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>
|
||||
<Field label="Display Order"><Input type="number" value={editing.order ?? 0} onChange={e => setEditing(p => ({ ...p, order: +e.target.value }))} /></Field>
|
||||
<Field label="Logo (upload or keep existing)">
|
||||
<Input type="file" accept="image/*" onChange={e => setLogoFile(e.target.files?.[0] ?? null)} />
|
||||
{editing.logoPath && !logoFile && <p className="text-xs text-gray-500 mt-1">Current: {editing.logoPath}</p>}
|
||||
</Field>
|
||||
<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 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);
|
||||
const token = getToken();
|
||||
await fetch('/api/minutes', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, 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>
|
||||
<h2 className="text-xl font-bold text-[#8C1D40] mb-4">Meeting Minutes</h2>
|
||||
<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 => (
|
||||
<div key={m._id} className="flex items-center justify-between bg-white border rounded p-3">
|
||||
<div>
|
||||
<p className="font-medium">{m.meetingType} — {m.date}</p>
|
||||
<p className="text-sm text-gray-500">{m.location}</p>
|
||||
</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);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('mcdi_token');
|
||||
setToken(null); setUser(null);
|
||||
};
|
||||
|
||||
if (!token || !user) return <Login onLogin={handleLogin} />;
|
||||
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 = () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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:8.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
|
||||
|
|
|
|||
|
|
@ -1,29 +1,34 @@
|
|||
{
|
||||
"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",
|
||||
"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.get('*', (_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 } });
|
||||
21
server/src/models/BoardMember.ts
Normal file
21
server/src/models/BoardMember.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface IBoardMember extends Document {
|
||||
name: string;
|
||||
position: string;
|
||||
email: string;
|
||||
image: string;
|
||||
bio: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
const BoardMemberSchema = new Schema<IBoardMember>({
|
||||
name: { type: String, required: true, trim: true },
|
||||
position: { type: String, required: true, trim: true },
|
||||
email: { type: String, required: true, trim: true, lowercase: true },
|
||||
image: { type: String, required: true },
|
||||
bio: { type: String, required: true },
|
||||
order: { type: Number, default: 0 },
|
||||
}, { 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);
|
||||
17
server/src/models/Minute.ts
Normal file
17
server/src/models/Minute.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface IMinute extends Document {
|
||||
date: string;
|
||||
meetingType: string;
|
||||
location: string;
|
||||
fileUrl: string;
|
||||
}
|
||||
|
||||
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 },
|
||||
}, { 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);
|
||||
48
server/src/routes/auth.ts
Normal file
48
server/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Router } from 'express';
|
||||
import { SignJWT } from 'jose';
|
||||
import * as argon2 from 'argon2';
|
||||
import { z } from 'zod';
|
||||
import { User } from '../models/User';
|
||||
import { requireAuth, AuthRequest } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { username, password } = loginSchema.parse(req.body);
|
||||
|
||||
const user = await User.findOne({ username });
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await argon2.verify(user.password, password);
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET ?? 'fallback-dev-secret');
|
||||
const token = await new SignJWT({ userId: user.id, role: user.role })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setExpirationTime('7d')
|
||||
.sign(secret);
|
||||
|
||||
res.json({ token, user: { id: user.id, username: user.username, name: user.name, role: user.role } });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, async (req: AuthRequest, res) => {
|
||||
const user = await User.findById(req.userId).select('-password');
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
res.json(user);
|
||||
});
|
||||
|
||||
export default router;
|
||||
45
server/src/routes/board.ts
Normal file
45
server/src/routes/board.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { BoardMember } from '../models/BoardMember';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { uploadHeadshot } from '../middleware/upload';
|
||||
|
||||
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 });
|
||||
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);
|
||||
});
|
||||
|
||||
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;
|
||||
33
server/src/routes/minutes.ts
Normal file
33
server/src/routes/minutes.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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({ date: -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);
|
||||
const minute = await Minute.create({ ...data, fileUrl });
|
||||
res.status(201).json(minute);
|
||||
});
|
||||
|
||||
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;
|
||||
89
server/src/seed.ts
Normal file
89
server/src/seed.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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 items = Array.from({ length: 18 }, (_, i) => ({
|
||||
imageUrl: `/photos/${String(i + 1).padStart(4, '0')}.jpg`,
|
||||
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' },
|
||||
{ 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' },
|
||||
]);
|
||||
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"]
|
||||
}
|
||||
|
|
|
|||
11
stack.env
11
stack.env
|
|
@ -1 +1,12 @@
|
|||
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!
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue