Compare commits

..

12 commits
v1 ... main

Author SHA1 Message Date
friday-bot
4fa0ff23d4 docs: rewrite all documentation for v2 stack (MongoDB, Express 5, /manage dashboard) 2026-05-27 17:21:09 -06:00
friday-bot
5fc019462c upgrade: mongo:7.0 now that AVX is enabled on production CPU 2026-05-27 17:09:22 -06:00
friday-bot
ccb4f22e57 fix: use 'mongo' shell for healthcheck (mongosh not in mongo:4.4) 2026-05-27 17:06:45 -06:00
friday-bot
a5e9109f13 fix: use mongo:4.4 for production servers without AVX support 2026-05-27 17:04:01 -06:00
friday-bot
8c2ddd2b2c fix: pass MongoDB init credentials via env_file, not shell interpolation 2026-05-27 17:02:53 -06:00
friday-bot
a50ce40d83 fix: use --legacy-peer-deps for client npm install in Dockerfile 2026-05-27 16:59:49 -06:00
friday-bot
cb610bb9c1 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)
2026-05-27 16:59:07 -06:00
friday-bot
b9c477cadf fix: seed all gallery photos that exist on disk
Previous seed generated paths 0001-0018.jpg but only 0001-0010.jpg
exist. Corrects to seed only 0001-0010.jpg and adds the extra
photos (1-6.jpg, IMG_1615(1).jpg) so all 17 images appear on deploy.
2026-05-27 16:57:03 -06:00
friday-bot
a164c1f956 Auth, drag-and-drop, image previews, rate limiting
- Login by email; board members are dashboard users (full access)
- Forced password change on first login; forgot/reset password via email
- Auto-send welcome email with set-password link when board member created
- 5 attempts / 5 min rate limit on login endpoint
- Board + Minutes tabs: drag-and-drop card reordering, persisted to DB
- PATCH /api/board/reorder and /api/minutes/reorder endpoints
- New minutes auto-insert at top (order 0)
- ImagePicker: inline preview thumbnail before upload (board, gallery, sponsors)
- Fix Minutes public sort: proper date parsing instead of broken string compare
- Remove server/dist/ from git tracking (.gitignore)
2026-05-27 16:49:10 -06:00
friday-bot
c8a9b7b1da Use mongo:7.0 (8.0 incompatible with kernel 6.19+) 2026-05-27 16:29:52 -06:00
friday-bot
b73bb0dbc1 Fix Express 5 catch-all route: use app.use instead of app.get('*') 2026-05-27 16:29:43 -06:00
friday-bot
ef8d281b2b 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
2026-05-27 16:23:12 -06:00
44 changed files with 4557 additions and 760 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.DS_Store
build/
node_modules/
server/dist/

View file

@ -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 --legacy-peer-deps
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"]

176
README.md
View file

@ -1,81 +1,145 @@
# Missoula Council of the Deaf, Inc. Website
# Missoula Council of the Deaf, Inc. (MCDi) Website
This repository contains the source code for the Missoula Council of the Deaf, Inc. (MCDi) website. The project is built using a modern web stack with React for the frontend and Express.js for the backend, both implemented in TypeScript. The application is containerized using Docker for easy deployment and scalability.
Official website for MCDi at [deafmissoula.org](https://deafmissoula.org). Built on a full-stack TypeScript architecture with a MongoDB backend and a React frontend.
## Features
## Tech Stack
- Responsive design using Tailwind CSS
- Dynamic UI components with React and Framer Motion for animations
- TypeScript for enhanced type safety and developer experience
- Express.js backend serving static React files and handling API requests
- Docker containerization for consistent development and deployment environments
- Calendar functionality with upcoming events display
- About Us section with board member information
- Bylaws and Minutes sections for organizational transparency
- Sponsor showcase
- Contact form in the footer
| Layer | Technology |
|-------|-----------|
| Frontend | React 19, TypeScript 5.8, Tailwind CSS, Framer Motion |
| Backend | Node.js 22 LTS, Express 5.1, TypeScript 5.8 |
| Database | MongoDB 7.0 (Mongoose 8.x ODM) |
| Auth | JWT (`jose` 5.x), `argon2` password hashing |
| File uploads | `multer` 1.4.5-lts |
| Email | `nodemailer` (Google Workspace SMTP) |
| Containerization | Docker, Docker Compose |
| Reverse proxy | Caddy (on production server) |
## Key Components
## Repository Structure
### Client-side
```
deafmissoula-website/
├── client/ # React frontend (Create React App)
│ ├── public/ # Static assets (photos, videos, PDFs, headshots)
│ └── src/
│ └── components/ # React components
├── server/ # Express backend
│ └── src/
│ ├── models/ # Mongoose models
│ ├── routes/ # API route handlers
│ ├── middleware/ # Auth, upload, error handling
│ ├── index.ts # Server entry point
│ ├── db.ts # MongoDB connection
│ └── seed.ts # Initial data seed script
├── docker-compose.yml # Production compose (2 containers: app + mongodb)
├── docker-compose.dev.yml # Dev override (no caddy_network requirement)
├── Dockerfile # Multi-stage build: client build + server compile
└── stack.env # Environment variables (committed — Gitea is internal-only)
```
- `App.tsx`: Main application component with routing setup
- `Header.tsx`: Navigation component
- `Home.tsx`: Landing page with featured content
- `Calendar.tsx`: Interactive calendar with event display
- `Meet-Board.tsx`: Board member profiles
- `Bylaws.tsx`: Organization bylaws display
- `Minutes.tsx`: Meeting minutes display
- `Sponsors.tsx`: Sponsor information and logos
- `Footer.tsx`: Footer component with contact form
## API Routes
### Server-side
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/auth/login` | — | Email + password login (rate limited: 5/5 min) |
| POST | `/api/auth/forgot-password` | — | Send password reset email |
| POST | `/api/auth/reset-password` | — | Set new password via reset token |
| POST | `/api/auth/change-password` | temp JWT | Forced password change on first login |
| GET | `/api/auth/me` | JWT | Current user info |
| GET/POST/PUT/DELETE | `/api/board` | JWT (write) | Board members |
| PATCH | `/api/board/reorder` | JWT | Drag-and-drop reorder |
| GET/POST/PUT/DELETE | `/api/events` | JWT (write) | Calendar events |
| GET/POST/PUT/DELETE | `/api/gallery` | JWT (write) | Photo gallery items |
| GET/POST/PUT/DELETE | `/api/minutes` | JWT (write) | Meeting minutes |
| PATCH | `/api/minutes/reorder` | JWT | Drag-and-drop reorder |
| GET/POST/PUT/DELETE | `/api/sponsors` | JWT (write) | Sponsors |
| GET/POST/PUT/DELETE | `/api/members` | JWT | Member directory |
| POST | `/api/email` | — | Contact form submission |
- `index.ts`: Express.js server setup
- `sendEmail.ts`: Email sending functionality for contact form
## Dashboard
## Getting Started
Available at `/manage` (intentionally not `/admin`). Board members log in with their `@deafmissoula.org` email address.
To run this project locally:
**First login flow:** Board members are created with `mustChangePassword: true`. On first login they receive a temp JWT and are immediately redirected to set their own password.
1. Clone the repository
2. Install Docker and Docker Compose
3. Create a `.env` file in the root directory with necessary environment variables
4. Run `docker-compose up --build` in the project root directory
5. Access the website at `http://localhost:5000`
**Forgot password:** Sends a 1-hour reset link to the member's email via Google Workspace SMTP.
## Development
**New board member:** When added via the dashboard, a welcome email is automatically sent with a 7-day set-password link.
- Client-side development: `cd client && npm start`
- Server-side development: `cd server && npm run dev`
- Build client: `cd client && npm run build`
- Build server: `cd server && npm run build`
## Environment Variables (`stack.env`)
## Deployment
```env
CACHEBUST=... # Increment to bust Docker layer cache
MONGO_INITDB_ROOT_USERNAME=mcdi
MONGO_INITDB_ROOT_PASSWORD=...
MONGO_INITDB_DATABASE=mcdi
MONGODB_URI=mongodb://mcdi:...@mongodb:27017/mcdi?authSource=admin
JWT_SECRET=...
ADMIN_PASSWORD=... # Initial password for admin@deafmissoula.org
APP_URL=https://deafmissoula.org # Used in password reset email links
```
The project is set up for easy deployment using Docker. The `Dockerfile` includes a multi-stage build process for both client and server, optimizing the final image size.
Google email credentials live in `.env` (not committed):
```env
GOOGLE_EMAIL=system@deafgain.org
GOOGLE_APP_PASSWORD=...
```
## Deployment with Docker Compose
## Local Development (Preview)
This project is containerized using Docker for easy deployment. Follow these steps to deploy the MCDi website:
```bash
# Start MongoDB separately (or use docker compose)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
1. Ensure Docker and Docker Compose are installed on your system.
# Seed the database (first run only)
cd server && npm run seed
2. Navigate to the project root directory containing the docker-compose.yml file.
# Dev servers
cd client && npm start # React dev server on :3000
cd server && npm run dev # Express with tsx watch on :801
```
3. Create a .env file in the root directory with the necessary environment variables:
NODE_ENV=production
GOOGLE_EMAIL=your-email@gmail.com GOOGLE_APP_PASSWORD=your-app-password
## Production Deployment
4. Build and start the containers:
docker-compose up --build
Production server: `10.4.0.205` (WireGuard VPN required)
Site path: `/home/chaulmark/websites/deafmissoula.org/`
5. Access the application at http://localhost:4000
```bash
ssh chaulmark@10.4.0.205
cd ~/websites/deafmissoula.org
git pull origin main
docker compose down
docker compose up -d --build
docker exec deafmissoulaorg-app-1 node dist/seed.js # First deploy only
```
6. To stop the containers:
docker-compose down
**Note on Caddy:** The app container joins the external `caddy_network`, so Caddy can route to it by container name:
```
deafmissoula.org {
reverse_proxy deafmissoulaorg-app-1:801
}
```
If you see a 502 after a rebuild, check that the container name hasn't changed (`docker ps`).
7. Use Docker Compose in detached mode:
docker-compose up -d
## Rollback to v1
This setup allows for easy deployment and management of the MCDi website container, with the ability to scale and update as needed using Docker Compose.
The last hardcoded (no-database) version is tagged `v1` in Git:
```bash
ssh chaulmark@10.4.0.205
cd ~/websites/deafmissoula.org
git checkout v1
docker compose down
docker compose up -d --build
```
v1 had no MongoDB container — `docker compose down` will cleanly remove the v2 stack. The MongoDB data volume (`mcdi-mongo-data`) is preserved unless you explicitly run `docker compose down -v`.
## Docker Volumes
| Volume | Purpose |
|--------|---------|
| `mcdi-mongo-data` | MongoDB data (persists across rebuilds) |
| `mcdi-uploads-data` | User-uploaded files via dashboard (`/uploads/`) |
Static assets baked into the image (photos, videos, PDFs from `client/public/`) are served directly from `client/build/` at their original paths (e.g. `/photos/0001.jpg`, `/minutes/minutes-04122025.pdf`). New uploads from the dashboard go to `/uploads/headshots/`, `/uploads/photos/`, etc.

View file

@ -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>
);
};

View file

@ -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) => {

View file

@ -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);

View file

@ -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 (

View 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;

View file

@ -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. Shes 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 shes not pushing for accessibility and equity, youll 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 = () => {

View file

@ -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) => {

View file

@ -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">

View file

@ -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;
}

View file

@ -1,177 +1,52 @@
# Active Context
## Current Status (March 10, 2026)
**Website Fully Operational** - All features deployed and verified working correctly
- Events calendar current and accurate
- All galleries (Photo, Video, Sponsors) functioning
- Minutes component working with latest documents
- Mobile donation button displaying properly
- All React 19 upgrades stable in production
- NEW: Resources page with Montana & National Deaf resources directory
## Current Status (May 27, 2026)
## Recent Changes
1. **Mobile Menu Fix (March 10, 2026)**: ✅ FULLY DEPLOYED AND VERIFIED
- Fixed mobile hamburger menu to include all items from desktop "About MCDi" dropdown
- Added missing items: Meet MCDi Board, Bylaws, Minutes, Sponsors
- Mobile menu now shows all 11 navigation items (complete parity with desktop)
- Committed to Git: bfa0756
- Pushed to remote repository
- Deployed to production server (10.4.0.205)
- Docker container rebuilt with --no-cache flag
- Verified working on public website (HTTP 200)
**v2 Fully Deployed** — MongoDB backend live on production
2. **Resources Page (March 10, 2026)**: ✅ FULLY DEPLOYED AND VERIFIED
- Created comprehensive Resources directory with Montana and National Deaf resources
- Organized into 9 categories:
* National Advocacy
* Montana Advocacy & Legal Support
* Family & Community Support
* Language Access & ASL Resources
* Education
* Employment Services
* Interpreter & Communication Access
* Montana Deaf Community Organizations
* Deaf Youth Camps, Sports & Leadership
- Professional card-based design with contact information (phone, email, address, website)
- Responsive 2-column layout on desktop, single column on mobile
- Smooth animations with Framer Motion
- Added to navigation: "About MCDi" dropdown and mobile menu
- Route: /resources
- Committed to Git: added54
- Pushed to remote repository
- Deployed to production server (10.4.0.205)
- Docker container rebuilt successfully
- Verified working on public website (HTTP 200)
The site completed a full backend migration from a hardcoded static Express app (v1) to a MongoDB-backed API with a board management dashboard (v2). Frontend is visually identical to v1 — no public-facing changes.
2. **Events Calendar Update (March 4, 2026)**: ✅ FULLY DEPLOYED AND VERIFIED
- Removed 2 past events: January 11, 2025 Members Meeting and December 13, 2025 Board Meeting
- Updated March 14, 2026 Board Meeting with complete details:
* Location: Funk It Coffee and Thrift
* Address: 314 N 1st St. West, Missoula MT 59802
* Time: 10:00 AM - 11:00 AM (changed from 9:00 AM - 10:00 AM)
* Contact: Tessa Williams
- Kept all future events (June 2026 and September 2026 meetings)
- Committed to Git: 3eff1a9
- Pushed to remote repository
- Deployed to production server (10.4.0.205)
- Docker container rebuilt with --no-cache flag
- Verified working on public website (after browser cache clear)
- Changes are live and visible to public
## What Changed in v2
2. **Web Package Updates (Dec 15, 2025)**: ✅ FULLY COMPLETED
- Updated React 18.2.0 → 19.2.3 (major version upgrade)
- Updated React Router 6.17.0 → 7.10.1 (major version upgrade)
- Updated Framer Motion 10.16.4 → 12.23.26 (major version upgrade)
- Updated @types/react 18.2.33 → 19.2.7
- Updated @types/react-dom 18.2.14 → 19.2.3
- Added missing dependencies: ajv@8.17.1, react-icons@5.5.0, date-fns@4.1.0
- Fixed React 19 breaking change: Updated index.tsx to use createRoot API
- Removed old conflicting index.js file (had old ReactDOM.render)
- Fixed index.html by removing incorrect script tag
- Fixed Tailwind CSS styling by adding index.css import to index.tsx
- All changes tested and verified working on local dev server
- Committed to Git: 9b964ea, 38773db, 45d4ff3, 3b88fc8
- Pushed to remote repository - ready for production deployment
- All previously hardcoded data (board members, events, gallery, minutes, sponsors) now lives in MongoDB and is editable via the `/manage` dashboard
- Board members can log in at `/manage` using their `@deafmissoula.org` email address
- First login forces a password change; forgot-password flow sends reset link via email
- Dashboard supports drag-and-drop reordering for Minutes and Board Members tabs
- Image/file uploads go to a persistent Docker volume (`mcdi-uploads-data`)
- MongoDB runs in a separate container (`mcdi-mongo-data` volume)
2. SBS Solar Sponsor Addition (Oct 15, 2025):
- Added sbs-solar.png logo to client/public/sponsors/
- Updated Sponsors.tsx with SBS Solar information
- Included website, Facebook, and Instagram links
- Description highlights renewable energy and community engagement
- Committed to Git: 8d4c058
- Pushed to remote repository for deployment
## Board Member Accounts
1. Video Gallery with Thumbnails (Oct 14, 2025):
- Extracted thumbnails from all 6 videos at 5-second mark using ffmpeg
- Thumbnail sizes: 80-183 KB each (total ~740 KB)
- Updated VideoGallery.tsx to display thumbnail images instead of video elements
- Thumbnails show actual video content preview with play button overlay
- Improved loading performance - thumbnails load instantly
- Deleted entire Junk directory (cleaned up all source files)
- Committed to Git: 4f6bc08
- Successfully deployed to production
All four board members are seeded with accounts. Passwords are set via the `/manage` dashboard or the welcome email flow. On first login each member is forced to set their own password.
2. Previous: Video Gallery creation and deployment:
- Optimized 5 MOV videos from Junk directory using ffmpeg (H.264, CRF 28, 128k audio)
- Created community-01.mp4 through community-05.mp4 (total ~36 MB)
- Moved archived board intro video as board-intro-archive.mp4 (128 MB)
- Created VideoGallery.tsx component with modal video player
- Video player features: play/pause/stop controls, seek bar, auto-hiding controls
- Responsive grid layout (1-3 columns based on screen size)
- Added to navigation: "Video Gallery" in About MCDi dropdown and mobile menu
- Route added: /videos
- Fixed TypeScript errors with FaPlay icon (used size/color props, Tailwind opacity)
- Committed to Git: b28bafa
| Name | Email | Position |
|------|-------|----------|
| Rita Brandborg | rita.brandborg@deafmissoula.org | President |
| Skyla Wilson | skyla.wilson@deafmissoula.org | Vice President |
| Tessa Williams | tessa.williams@deafmissoula.org | Secretary |
| Aubz M | aubz.m@deafmissoula.org | Treasurer |
2. Previous: Gallery photo optimization and renaming:
- Renamed gallery photos to 0001.jpg through 0010.jpg format
- Converted 8 HEIC files from Junk directory to JPG using macOS sips tool
- Optimized all photos for web (resized to max 1920px, 80% quality)
- Removed all captions per owner request (photos display without text)
- Gallery shows 10 community event photos in responsive grid
- Committed to Git: a92839f
Admin fallback: `admin@deafmissoula.org` (password in `stack.env``ADMIN_PASSWORD`)
3. Previous: Created new Gallery component (Gallery.tsx):
- Responsive photo grid layout (1-4 columns based on screen size)
- Clickable images that enlarge in a modal viewer
- Smooth animations using Framer Motion
- Modal features: click outside to close, X button, spring animation
- Maintains website's color scheme (#6B1631 red)
- Added to navigation: "About MCDi" dropdown and mobile menu
- Route added: /gallery
## Current Deployment
2. Previous: Modified Header.tsx to display donation button on mobile:
- Added permanent yellow donation button below hamburger menu on mobile devices
- Button is always visible in the red header area (no need to open menu)
- Removed duplicate donation button from inside mobile menu dropdown
- Desktop view unchanged - button remains in top right corner
- Mobile header now has extended height to accommodate the button
- Button centered and properly styled with same appearance as desktop version
2. Previous: Added 5 new events to eventData.ts:
- December 13, 2025: Board Meeting at Liquid Planet at The Book Exchange (9-10 AM)
- March 14, 2026: Board Meeting at TBD location (9-10 AM)
- June 13, 2026: Board Meeting at TBD location (9-10 AM)
- September 12, 2026: Board Meeting at TBD location (9-10 AM)
- September 12, 2026: Annual Membership Meeting at TBD location (10-11 AM)
- All events use Tessa Williams as contact (tessa.williams@deafmissoula.org)
- Events will automatically appear in calendar view and upcoming events section
2. Updated intro video:
- Replaced mcdi-intro.mp4 with new version
- Original video saved as mcdi-intro-old.mp4
- Video component in Meet-Board.tsx already configured to work with new file
- No code changes needed - video path remains /videos/mcdi-intro.mp4
2. Previous completed work:
- Added new meeting minutes for January 11, 2025 and March 8, 2025
- Added 6.jpg to rotating photo carousel
- Fixed CACHEBUST deployment issue with stack.env configuration
- Updated email configuration for production server compatibility (port 2525)
- All previous changes successfully deployed to production
- **Production server:** `10.4.0.205` (WireGuard VPN)
- **Site path:** `/home/chaulmark/websites/deafmissoula.org/`
- **Containers:** `deafmissoulaorg-app-1` (port 801) + `deafmissoulaorg-mongodb-1`
- **Git branch:** `main` (v2), `v1` tag = last hardcoded version
## Next Steps
1. Future tasks on hold:
- Minutes update - waiting for approved minutes from secretary
3. Monitor video gallery performance on production
4. Check if board-intro-archive.mp4 (128 MB) loads acceptably
5. Future: Could add video descriptions or categories if requested
6. Future: Could add video duration display on thumbnails
## Solution Summary
- Problem: Portainer repository deployments require stack.env file in Git repo
- Solution: Created stack.env with CACHEBUST variable and updated docker-compose.yml
- Future: Update CACHEBUST value in stack.env and push to force rebuilds
- [ ] Onboard remaining board members (Skyla, Tessa, Aubz) once Rita demo is confirmed successful
- [ ] Change default `stack.env` passwords from placeholders to strong values
- [ ] Update events in dashboard when new meetings are scheduled (no more code changes needed for content updates)
- [ ] Minutes PDFs: new PDFs go in `client/public/minutes/` (git commit + deploy) OR upload via dashboard (no deploy needed)
## Deployment Process
To deploy changes to production server:
1. SSH to production server: `ssh chaulmark@10.4.0.205`
2. Navigate to website directory: `cd ~/websites/deafmissoula.org`
3. Pull latest changes: `git pull`
4. Rebuild Docker container: `docker-compose up -d --build`
## Deployment Command
## Technical Notes
- Website uses React with TypeScript
- Minutes component includes desktop/mobile viewing modes
- Sorting functionality maintained for all columns
- Minutes data ordered from newest (top) to oldest (bottom) in the source array
```bash
ssh chaulmark@10.4.0.205
cd ~/websites/deafmissoula.org
git pull origin main
docker compose down && docker compose up -d --build
```

View file

@ -1,15 +1,32 @@
# Product Context
## Purpose
The MCDI website serves as the online presence for MCD Inc., providing information about the organization, its meetings, and resources to the public.
The MCDi website is the public-facing online presence for Missoula Council of the Deaf, Inc. It serves the Deaf community in Missoula and surrounding areas with information about the organization, its board, events, meeting minutes, and community resources.
## Problems Solved
- Provides easy access to meeting minutes and organizational documents
- Enables transparency through public access to meeting records
- Facilitates communication between the organization and its members/public
## Intended Functionality
- Display meeting minutes in a sortable table format
- Allow viewing of PDF documents both on desktop (popup viewer) and mobile (direct download)
- Present organizational information and updates
- Maintain historical records of meetings and decisions
- Public access to meeting minutes, bylaws, and organizational documents
- Transparent governance through accessible records
- Community event calendar
- Photo and video gallery of MCDi events
- Directory of Montana and National Deaf resources
- Sponsor recognition
- Contact form for public inquiries
## Dashboard (`/manage`) Purpose
Allows board members to manage website content without requiring developer involvement:
- **Board Members tab:** Add/edit/remove board member profiles and headshots; drag to reorder
- **Events tab:** Add/edit/remove calendar events
- **Gallery tab:** Upload new photos; reorder the gallery
- **Minutes tab:** Upload new meeting minutes PDFs; drag to reorder
- **Sponsors tab:** Add/edit/remove sponsors and logos
- **Members tab:** Internal member directory (board use only)
## Intended Users
- **Public:** Read-only access to all public pages
- **Board members:** Login at `/manage` to update content; each member has their own account via their `@deafmissoula.org` Google Workspace email
- **Admin (Chris):** Full access to codebase, database, and admin account; can make structural changes via Claude Code

View file

@ -1,156 +1,43 @@
# Progress Status
## Completed Features
1. Resources Page:
- Comprehensive directory of Montana and National Deaf resources
- 9 organized categories: Advocacy, Legal Support, Family/Community, ASL Resources, Education, Employment, Interpreters, Community Organizations, Youth Programs
- 25+ organizations listed with full contact information
- Professional card-based design with contact icons (phone, email, address, website)
- Responsive 2-column layout (desktop) / single column (mobile)
- Smooth animations using Framer Motion
- Clickable website links that open in new tabs
- Clickable email and phone links for easy contact
- Added to navigation in About MCDi dropdown and mobile menu
- Route: /resources
- Successfully deployed to production
## v2 — Current (deployed May 27, 2026)
2. Sponsors Page:
- Four sponsors displayed: Drum Coffee, Imagine Nation Brewing, GILD Brewing, SBS Solar
- Each sponsor has logo, description, and social media links (website, Facebook, Instagram)
- Responsive grid layout (1-3 columns based on screen size)
- Clickable cards open modal with full description
- Route: /sponsors
- Successfully deployed to production
### Completed
2. Video Gallery:
- Created VideoGallery component with modal video player
- Optimized 5 community videos using ffmpeg (H.264, CRF 28)
- Added archived board intro video (128 MB)
- Custom video controls: play/pause/stop, seek bar, auto-hiding controls
- Responsive grid layout (1-3 columns)
- Added to navigation in About MCDi dropdown and mobile menu
- Route: /videos
- Successfully deployed to production
- **MongoDB backend** — All data (board, events, gallery, minutes, sponsors, members) moved from hardcoded arrays to MongoDB 7.0; Mongoose 8.x models with full CRUD API
- **`/manage` dashboard** — Board member login, all content tabs, drag-and-drop reorder for Minutes and Board tabs, image preview before upload
- **Board member auth** — Email-based login, argon2 hashing, JWT sessions, forced password change on first login, forgot/reset password via Google Workspace email, auto welcome email on new member creation
- **Rate limiting** — 5 login attempts per 5 minutes on `/api/auth/login`
- **File uploads** — multer with persistent Docker volume (`mcdi-uploads-data`); images and PDFs uploadable via dashboard
- **Docker** — Two-container setup: app + MongoDB; named volumes for data persistence; `caddy_network` external network so Caddy can route by container name
- **v1 rollback tag**`git tag v1` in Gitea points to last hardcoded version
2. Photo Gallery:
- Created Gallery component with modal image viewer
- Optimized 10 community photos (HEIC to JPG, max 1920px, 80% quality)
- Renamed photos to 0001.jpg through 0010.jpg format
- No captions per owner request
- Responsive grid layout (1-4 columns)
- Added to navigation in About MCDi dropdown and mobile menu
- Route: /gallery
- Successfully deployed to production
### Existing Features (carried over from v1)
3. Minutes Component:
- Sortable table implementation
- PDF viewer integration
- Mobile/desktop responsive design
- Meeting entries through March 8, 2025
- Responsive frontend — React 19, Tailwind CSS, Framer Motion
- Calendar with upcoming events
- Photo gallery (17 photos: 00010010 + 16 + IMG_1615)
- Video gallery with thumbnails (5 community videos + board intro archive)
- Board member profiles with headshots and bios
- Meeting minutes table with sortable columns and PDF viewer
- Bylaws PDFs
- Sponsors page with logos and social links
- Resources directory (Montana + National Deaf organizations)
- Contact form (Google Workspace SMTP)
- Mobile donation button
4. Calendar Events:
- Updated March 4, 2026: Removed past events, updated March 14 meeting with Funk It Coffee location
- Current events: March 14, June 13, and September 12, 2026 meetings
- Events automatically appear in calendar view
## What's Next
5. Mobile Donation Button:
- Permanent yellow button below hamburger menu on mobile
- Always visible in red header area
- [ ] Onboard board members to dashboard (starting with Rita Brandborg)
- [ ] Change default passwords in `stack.env`
- [ ] Future: Additional gallery photos can be added directly via dashboard (no deploy needed)
- [ ] Future: New meeting minutes can be uploaded via dashboard PDF upload (no deploy needed)
- [ ] Future: Consider pagination on minutes table if list grows large
## Recent Progress
1. **Mobile Menu Fix (March 10, 2026)**: ✅ FULLY DEPLOYED AND VERIFIED
- Fixed mobile hamburger menu to include all items from desktop "About MCDi" dropdown
- Added missing navigation items: Meet MCDi Board, Bylaws, Minutes, Sponsors
- Mobile menu now shows complete parity with desktop (11 total items)
- Committed to Git: bfa0756
- Pushed to remote repository
- Deployed to production server (10.4.0.205)
- Docker container rebuilt with --no-cache flag
- Verified working on public website (HTTP 200)
## Git History Reference
2. **Resources Page (March 10, 2026)**: ✅ FULLY DEPLOYED AND VERIFIED
- Created comprehensive Resources directory with Montana and National Deaf resources
- Organized into 9 categories covering advocacy, support, education, employment, and youth programs
- Professional card-based design with full contact information
- Responsive layout with smooth animations
- Added to navigation in About MCDi dropdown and mobile menu
- Committed to Git: added54
- Pushed to remote repository
- Deployed to production server (10.4.0.205)
- Docker container rebuilt successfully
- Verified working on public website (HTTP 200)
2. **Events Calendar Update (March 4, 2026)**: ✅ FULLY DEPLOYED AND VERIFIED
- Removed 2 past events (January 11, 2025 and December 13, 2025)
- Updated March 14, 2026 Board Meeting with complete details:
* Location: Funk It Coffee and Thrift
* Address: 314 N 1st St. West, Missoula MT 59802
* Time: 10:00 AM - 11:00 AM (changed from 9:00 AM - 10:00 AM)
- Kept future events (June 2026 and September 2026 meetings)
- Committed to Git: 3eff1a9
- Pushed to remote repository
- Deployed to production server (10.4.0.205)
- Docker container rebuilt with --no-cache flag
- Verified working on public website (after browser cache clear)
- Changes are live and visible to public
2. **Web Package Updates (Dec 15, 2025)**: ✅ FULLY COMPLETED
- Updated all major web packages to latest versions
- React 18.2.0 → 19.2.3 (major version with new createRoot API)
- React Router 6.17.0 → 7.10.1 (major version upgrade)
- Framer Motion 10.16.4 → 12.23.26 (latest animation library)
- Updated TypeScript type definitions for React 19
- Added missing dependencies: ajv@8.17.1, react-icons@5.5.0, date-fns@4.1.0
- Fixed React 19 breaking changes (createRoot API in index.tsx)
- Removed old conflicting index.js file
- Fixed index.html by removing incorrect script tag
- Fixed Tailwind CSS styling by adding index.css import
- All changes tested and verified working on local dev server
- Build successful with only minor ESLint warnings
- Committed to Git: 9b964ea, 38773db, 45d4ff3, 3b88fc8
- Pushed to remote repository
- Ready for production Docker rebuild
2. SBS Solar Sponsor Addition (Oct 15, 2025):
- Added sbs-solar.png logo (70 KB) to client/public/sponsors/
- Updated Sponsors.tsx with SBS Solar entry
- Added website: https://www.sbssolar.com/
- Added Facebook: https://www.facebook.com/sbssolar
- Added Instagram: https://www.instagram.com/sbssolar/
- Description emphasizes renewable energy and community engagement
- Committed to Git: 8d4c058
- Pushed to remote repository
- Ready for Docker rebuild and deployment
2. Video Gallery Thumbnails (Oct 14, 2025):
- Extracted thumbnails from all 6 videos at 5-second mark using ffmpeg
- Created 6 thumbnail JPG files (80-183 KB each, total ~740 KB)
- Updated VideoGallery.tsx to use thumbnail images for instant preview
- Thumbnails display actual video content with play button overlay
- Significantly improved loading performance
- Deleted entire Junk directory (cleaned up all source files)
- Deployed successfully (commit 4f6bc08)
2. Video Gallery Implementation (Oct 14, 2025):
- Converted 5 MOV files to optimized MP4 (total ~36 MB)
- Created VideoGallery.tsx with same player design as Meet-Board
- Fixed TypeScript errors with FaPlay icon
- Deployed successfully (commit b28bafa)
3. Photo Gallery Updates (Oct 14, 2025):
- Renamed all gallery photos to numbered format (0001-0010)
- Removed captions from photo gallery
- Deployed successfully (commit a92839f)
## What's Left
1. On Hold (waiting for information):
- Minutes update - waiting for approved minutes from secretary
3. Future Considerations:
- Add more sponsors as partnerships develop
- Add video descriptions or categories if requested
- Add video duration display on thumbnails
- Consider optimizing board-intro-archive.mp4 if needed
- Monitor for additional meeting minutes
- Consider pagination if minutes list grows large
- Use stack.env CACHEBUST method for future forced deployments
| Tag/Commit | Description |
|-----------|-------------|
| `v1` | Last hardcoded version (no database) |
| `main` | Current v2 production branch |
| `development` | Merged into main May 27, 2026 — v2 development branch |

View file

@ -1,39 +1,88 @@
# System Patterns
## Architecture
- React-based frontend
- TypeScript for type safety
- Tailwind CSS for styling
- Docker containerization
- Portainer for deployment management
## Component Patterns
1. Minutes Component
- Table-based display with sorting
- Responsive design (desktop/mobile)
- PDF viewer integration
- State management with React hooks
Single-container Express app serves both the React SPA and the REST API. MongoDB runs in a separate container on an internal Docker network.
```
Browser
└─► Caddy (caddy_network)
└─► deafmissoulaorg-app-1:801
├─ GET /api/* → Express routes
├─ GET /uploads/* → multer upload volume
├─ GET /photos/*, /videos/*, etc. → client/build/ static assets
└─ GET * → client/build/index.html (SPA catch-all)
└─► deafmissoulaorg-mongodb-1:27017 (internal network only)
```
## Data Flow
All frontend data is fetched from the API at runtime — nothing is hardcoded in components. Each component does a `useEffect``fetch('/api/...')``useState` pattern.
## File Organization
```
client/
├── public/
│ ├── minutes/ # PDF files
│ └── ...
├── src/
│ ├── components/ # React components
│ └── ...
└── ...
client/src/components/
├── Manage.tsx # /manage dashboard (auth + all admin tabs)
├── App.tsx # Routing: /manage/* outside Header/Footer
├── Home.tsx # Landing page (fetches events)
├── Calendar.tsx # Events calendar (fetches events)
├── Meet-Board.tsx # Board member profiles (fetches /api/board)
├── Gallery.tsx # Photo gallery (fetches /api/gallery)
├── VideoGallery.tsx # Video gallery (hardcoded paths in client/public/videos/)
├── Minutes.tsx # Meeting minutes table (fetches /api/minutes)
├── Sponsors.tsx # Sponsors (fetches /api/sponsors)
├── Bylaws.tsx # Static PDF links (hardcoded paths in client/public/)
└── Resources.tsx # Montana/National Deaf resources directory (static)
server/src/
├── models/ # Mongoose schemas
│ ├── BoardMember.ts # Includes auth fields (password, mustChangePassword, resetToken)
│ ├── Event.ts
│ ├── GalleryItem.ts
│ ├── Minute.ts # Has 'order' field for drag-and-drop sorting
│ ├── Sponsor.ts
│ ├── Member.ts
│ └── User.ts # Admin-only account (not BoardMember)
├── routes/
│ ├── auth.ts # Login, forgot/reset password, change password
│ ├── board.ts # CRUD + reorder + welcome email on create
│ ├── minutes.ts # CRUD + reorder (new entries auto-insert at top)
│ ├── events.ts
│ ├── gallery.ts
│ ├── sponsors.ts
│ ├── members.ts
│ └── email.ts # Contact form
├── middleware/
│ ├── auth.ts # JWT verification, exports AuthRequest
│ ├── upload.ts # Multer config for headshots/photos/sponsors/minutes
│ └── errorHandler.ts # ZodError → 400, others → 500
├── db.ts # mongoose.connect()
├── index.ts # Express app setup, static serving, route mounting
└── seed.ts # One-time data population (idempotent — skips if data exists)
```
## Dashboard (`/manage`) Patterns
- **Route security:** `/manage` is not linked from public nav (obscurity) + JWT required for all write operations
- **Login:** Checks BoardMember by email first, then User (admin fallback)
- **Drag-and-drop:** `useDragReorder<T>` custom hook using HTML5 drag API + `useRef` for drag index tracking; calls `PATCH /api/[resource]/reorder` with `{ ids: string[] }` on drop
- **Image preview:** `URL.createObjectURL(file)` in `ImagePicker` component before upload
- **Forced password change:** `mustChangePassword: true` → server issues `tempToken` (1-hour) → client shows change-password form before dashboard loads
## Deployment Pattern
1. Code changes pushed to Gitea
2. Container rebuild triggered via Portainer
3. New version deployed automatically
## Key Technical Decisions
- PDF viewing:
- Desktop: In-page popup viewer
- Mobile: Direct file download
- Sorting: Client-side implementation
- State: Local component state (useState)
1. Code changes pushed to Gitea (`git push origin main`)
2. SSH to production server (`ssh chaulmark@10.4.0.205`)
3. `git pull origin main && docker compose down && docker compose up -d --build`
No Portainer. No webhooks. Manual pull + rebuild.
## Rollback Pattern
`v1` tag in Git points to the last hardcoded version (no database):
```bash
git checkout v1
docker compose down
docker compose up -d --build
```

View file

@ -1,95 +1,90 @@
# Technical Context
## Technologies Used
- Frontend:
- React
- TypeScript
- Tailwind CSS
- Framer Motion (animations)
- Infrastructure:
- Docker
- Portainer
- Gitea (version control)
## Stack (v2 — current)
## Development Setup
1. Source code:
- Repository hosted on Gitea
- TypeScript for type safety
- React components in client/src/components/
### Frontend
- **React 19** with TypeScript 5.8
- **Tailwind CSS** (via PostCSS, built into `src/tailwind.css`)
- **Framer Motion 12.x** for animations
- **React Router 7.x** for client-side routing
- Build tool: Create React App (`react-scripts 5.0.1`)
- Install note: `npm install --legacy-peer-deps` required (react-scripts peer dep conflict with TS 5.x)
2. Build/Deploy:
- Docker container
- Portainer for container management
- Automatic rebuilds on code changes
### Backend
- **Node.js 22 LTS**, **Express 5.1**, TypeScript 5.8
- **Mongoose 8.x** ODM → **MongoDB 7.0**
- **`jose` 5.x** for JWT signing/verification
- **`argon2`** for password hashing (native compilation — requires `python3 make g++` in Docker)
- **`multer` 1.4.5-lts.1** for file uploads
- **`nodemailer` 6.x** for email via Google Workspace SMTP
- **`express-rate-limit` 8.5.x** on auth routes (5 attempts / 5 min)
- **`zod` 3.x** for request validation
- Dev runner: `tsx watch`
## Technical Constraints
1. File Management:
- PDF files stored in client/public/minutes/
- Static file serving for documents
### Express 5 Breaking Change
`app.get('*', handler)` is **invalid** in Express 5. SPA catch-all must use:
```typescript
app.use((_req, res) => { res.sendFile(path.join(clientBuild, 'index.html')); });
```
2. Browser Compatibility:
- Desktop: PDF viewer in popup
- Mobile: Direct PDF download
## Infrastructure
3. Dependencies:
- React for UI components
- TypeScript for type checking
- Framer Motion for animations
- Tailwind for styling
### Docker (Production)
Two containers managed by `docker-compose.yml`:
- `deafmissoulaorg-app-1` — Node.js app (Express serves React build + API) on port 801
- `deafmissoulaorg-mongodb-1` — MongoDB 7.0, internal network only (not exposed)
## Production Server Configuration (10.4.0.205)
Named volumes:
- `mcdi-mongo-data` — MongoDB data
- `mcdi-uploads-data` — Dashboard file uploads
Networks:
- `caddy_network` (external) — shared with Caddy reverse proxy; app joins this so Caddy can route by container name
- `internal` (bridge) — app ↔ MongoDB only
### Caddy Reverse Proxy
- **Location:** `~/docker/caddy/config/Caddyfile`
- **Container Name:** `caddy`
- **Admin API:** `0.0.0.0:2019`
- **Production server:** `10.4.0.205` (WireGuard VPN)
- **Config location:** `~/docker/caddy/config/Caddyfile`
### DeafMissoula.org Configuration
- **Docker Container:** `deafmissoulaorg-app-1`
- **Internal Port:** 801
- **Docker Network:** Separate from Caddy (different network IDs)
- **Current IP:** 172.18.0.6 (as of March 4, 2026)
**Important:** Because Caddy and deafmissoula containers are on different Docker networks, the Caddyfile must use IP addresses instead of container names for routing.
### Caddyfile Entry:
Because the app joins `caddy_network`, Caddy can use the container name:
```
deafmissoula.org {
reverse_proxy 172.18.0.6:801
reverse_proxy deafmissoulaorg-app-1:801
}
```
### Common Issues & Solutions
If you see a 502 after a rebuild, check the container name hasn't changed:
```bash
docker ps --filter name=deafmissoula
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
```
#### Issue: Website returns 502 after Docker rebuild
**Cause:** Docker container IP address changed during rebuild
### Production Server CPU Note
The Proxmox VM running 10.4.0.205 has CPU type set to `host` (passthrough). This is required for MongoDB 7.0+ which needs AVX instructions. Do not change the VM CPU type back to `kvm64` or `Common KVM processor` — MongoDB will crash.
**Solution:**
1. Check current container IP:
```bash
ssh chaulmark@10.4.0.205 "docker inspect deafmissoulaorg-app-1 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'"
```
## Authentication
2. Update Caddyfile:
```bash
# Download Caddyfile
scp chaulmark@10.4.0.205:~/docker/caddy/config/Caddyfile /tmp/Caddyfile
# Edit locally to update IP address in deafmissoula.org section
# Upload back
scp /tmp/Caddyfile chaulmark@10.4.0.205:~/docker/caddy/config/Caddyfile
```
- Board members log in at `/manage` with their `@deafmissoula.org` email
- First login: `mustChangePassword: true` → server returns `tempToken` (1-hour JWT) → client redirects to force-change screen
- Forgot password: generates `resetToken` + `resetTokenExpiry` (1 hour) on BoardMember, emails `${APP_URL}/manage/reset-password?token=xxx`
- New board member added via dashboard: auto-sends welcome email with 7-day reset link
- Admin fallback: `admin@deafmissoula.org` is a `User` model (not BoardMember), password set via `ADMIN_PASSWORD` in `stack.env`
3. Reload Caddy:
```bash
ssh chaulmark@10.4.0.205 "docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
```
## File Serving
4. Verify:
```bash
curl -I https://deafmissoula.org
```
Two separate file trees:
1. **Static assets** (baked into Docker image from `client/public/`) served at root paths: `/photos/`, `/videos/`, `/minutes/`, `/headshots/`, `/sponsors/`, `/logos/`
2. **Dashboard uploads** (Docker volume `mcdi-uploads-data`) served at `/uploads/headshots/`, `/uploads/photos/`, `/uploads/sponsors/`, `/uploads/minutes/`
#### Better Long-term Solution
Connect both Caddy and deafmissoula containers to the same Docker network to use container names instead of IP addresses, which would prevent this issue.
## Deployment
```bash
ssh chaulmark@10.4.0.205
cd ~/websites/deafmissoula.org
git pull origin main
docker compose down && docker compose up -d --build
# First deploy only:
docker exec deafmissoulaorg-app-1 node dist/seed.js
```
CACHEBUST in `stack.env` forces a full Docker layer cache bust when incremented. Update it if a rebuild is pulling stale cached layers.

9
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,9 @@
services:
app:
networks:
- internal
networks:
caddy_network:
name: caddy_network_dev
driver: bridge

View file

@ -1,4 +1,3 @@
version: '3'
services:
app:
build:
@ -7,17 +6,54 @@ 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
command: ["--auth", "--bind_ip_all"]
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
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

File diff suppressed because it is too large Load diff

View file

@ -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
View 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');
}

View file

@ -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);
});

View 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' });
}
}

View 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' });
}

View 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 } });

View 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);

View 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);

View 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);

View 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);

View 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);

View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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); });

View file

@ -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"]
}

View file

@ -1 +1,16 @@
CACHEBUST=1736003093
# MongoDB
MONGO_INITDB_ROOT_USERNAME=mcdi
MONGO_INITDB_ROOT_PASSWORD=changeme-set-a-strong-password
MONGO_INITDB_DATABASE=mcdi
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