Fresh update

This commit is contained in:
TheMaddax 2024-10-14 14:15:30 -05:00
parent aea0071b3b
commit 2521489cad
49 changed files with 1588 additions and 212 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
*.jpg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.pdf filter=lfs diff=lfs merge=lfs -text

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

View file

@ -1,26 +1,54 @@
# Build stage for client
FROM node:14 AS client-build
WORKDIR /app/client
COPY client/package*.json ./
RUN npm install
COPY client/ ./
RUN npm run build -- --mode production
# Build stage
FROM node:14 AS build
# Build stage for server
FROM node:14 AS server-build
WORKDIR /app/server
COPY server/package*.json ./
RUN npm install
COPY server/ ./
RUN npm run build
WORKDIR /app
# Copy package.json and package-lock.json
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 server && npm install
# Copy source code
COPY . .
# Install nodemailer and dotenv
RUN npm install nodemailer dotenv date-fns react-icons
# 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
# Production stage
FROM node:14-alpine
WORKDIR /app
COPY --from=client-build /app/client/build ./client/build
COPY --from=server-build /app/server/dist ./server/dist
COPY --from=server-build /app/server/package*.json ./server/
# Copy built assets from build stage
COPY --from=build /app/client/build ./client/build
COPY --from=build /app/server ./server
COPY --from=build /app/.env ./.env
# Set working directory to server
WORKDIR /app/server
# Install production dependencies
RUN npm install --only=production
# Expose port
EXPOSE 5000
CMD ["node", "dist/index.js"]
# Start the server
CMD ["npm", "start"]

View file

@ -1,22 +1,38 @@
# Missoula Council of the Deaf, Inc. Website
This repository contains the source code for the Missoula Council of the Deaf, Inc. (MCDi) website. The project is built using a MERN stack (MongoDB, Express, React, Node.js) with TypeScript and is containerized using Docker.
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.
## Features
- Responsive design
- Animated UI components using Framer Motion
- Docker containerization for easy deployment
- Express.js backend serving static React files
- 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
## Project Structure
## Key Components
The project is organized into client and server directories:
### Client-side
- `client/`: React frontend application
- `server/`: Express.js backend application
- `Dockerfile`: Multi-stage build for both client and server
- `docker-compose.yml`: Docker Compose configuration for running the application
- `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
### Server-side
- `index.ts`: Express.js server setup
- `sendEmail.ts`: Email sending functionality for contact form
## Getting Started
@ -24,5 +40,42 @@ To run this project locally:
1. Clone the repository
2. Install Docker and Docker Compose
3. Run `docker-compose up --build` in the project root directory
4. Access the website at `http://localhost:5000`
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`
## Development
- 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`
## Deployment
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.
## Deployment with Docker Compose
This project is containerized using Docker for easy deployment. Follow these steps to deploy the MCDi website:
1. Ensure Docker and Docker Compose are installed on your system.
2. Navigate to the project root directory containing the docker-compose.yml file.
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
4. Build and start the containers:
docker-compose up --build
5. Access the application at http://localhost:4000
6. To stop the containers:
docker-compose down
7. Use Docker Compose in detached mode:
docker-compose up -d
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.

View file

@ -0,0 +1,2 @@
Timestamp,Event Date,End Date (if multi-day event),Event Type,Custom Other Event Type (if applicable),Event Name,Location,Start Time,End Time,Name of Point of Contact,Notes,Email address of Point of Contact
10/10/2024 20:16:30,10/10/2024,,Community Outreach,,DeafBlind Retreat,Missoula Coffee Cafe,10:00:00 AM,12:00:00 PM,Chris Haulmark,Meet and learn from the DeafBlind about accessibility issues around Missoula.,chris@sigd.net
1 Timestamp Event Date End Date (if multi-day event) Event Type Custom Other Event Type (if applicable) Event Name Location Start Time End Time Name of Point of Contact Notes Email address of Point of Contact
2 10/10/2024 20:16:30 10/10/2024 Community Outreach DeafBlind Retreat Missoula Coffee Cafe 10:00:00 AM 12:00:00 PM Chris Haulmark Meet and learn from the DeafBlind about accessibility issues around Missoula. chris@sigd.net

BIN
client/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1,44 +1,53 @@
{
"name": "mcdi-website-client",
"version": "1.0.0",
"private": true,
"homepage": ".",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.10.0",
"styled-components": "^5.3.9",
"framer-motion": "^10.12.4"
},
"devDependencies": {
"@types/react": "^18.0.37",
"@types/react-dom": "^18.0.11",
"@types/styled-components": "^5.1.26",
"typescript": "^5.0.4",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "GENERATE_SOURCEMAP=false react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
"name": "mcdi-website-client",
"version": "1.0.0",
"private": true,
"dependencies": {
"@types/node": "^20.8.9",
"@types/react": "^18.2.33",
"@types/react-dom": "^18.2.14",
"@types/react-router-dom": "^5.3.3",
"framer-motion": "^10.16.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.17.0",
"react-scripts": "5.0.1",
"styled-components": "^6.1.0",
"typescript": "^5.2.2"
},
"scripts": {
"build:css": "postcss src/index.css -o src/tailwind.css",
"start": "react-scripts start",
"build": "npm run build:css && react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"postcss-cli": "^8.3.1",
"@types/styled-components": "^5.1.29",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.23.2",
"@babel/preset-react": "^7.22.15",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11"
}}

6
client/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
]
}

BIN
client/public/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2adfd474d91fd20c51084309ed000c1ae6cc7f5f70af14d375930f5a71301308
size 54771

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:47c65b7dcc296d6db7e9b500b9aeb3fd6bd2b3d80e6dcb5e00b6383d8eec0a7c
size 22027

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d13c582ed01aeabdceff0203d09ffc4aa589f6092173edec9770c580c82dad28
size 16293

View file

@ -16,5 +16,6 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/static/js/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a714ba95a0ee8851eb5402c350a36d593a450b3c0b24ee93cae426e47dd6a5b
size 143884

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c445b01ceb93e20e37f10875a001f8520520846188fc3eff7090895ba44ddbc9
size 44554

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1b412a6d87a08ff2b41a01f5a07f4beeccdcaeea5ec55c153240a70e85367c45
size 32364

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:99702c36cef60bee6edda6066a6620532e78806c826ab45a5c8dba26430c1482
size 346854

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8d82e4cbb4e13fda8a220de097f9a0ae878b51731fc7b9b97cdf72903697d623
size 637203

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:076c577be3510a6b0d4e7642eb87cdfae9060584e759babe64ea2171b8838fe0
size 306688

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d48dea4ab678975cdd2044b54ebf229e5f5c1371035a0a82112f189c1bb9a862
size 520266

BIN
client/public/sponsors/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:67ef119e8607077db7675d3c2610104d893ea5d85bc8a40f7050295e6155064b
size 150487

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b5fdb9b6d517bc5888c56b296940b8b15bc33535c5542233b14ab33822cbfd34
size 547385

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db404ce802400b3ade2eceeef3dec9b5e322ef41ceb2862c66cfa38fc8f1f946
size 552504

View file

@ -1,22 +1,34 @@
import React from 'react';
import styled from 'styled-components';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Header from './components/Header';
import Home from './components/Home';
import Calendar from './components/Calendar';
import Minutes from './components/Minutes';
import AboutUs from './components/AboutUs';
import Footer from './components/Footer';
import ComingSoon from './components/ComingSoon';
const AppContainer = styled.div`
min-height: 100vh;
position: relative;
padding-bottom: 60px; // Height of the footer
`;
import Bylaws from './components/Bylaws';
import Sponsors from './components/Sponsors';
import MeetBoard from './components/Meet-Board';
const App: React.FC = () => {
return (
<AppContainer>
<Header />
<ComingSoon />
<Footer />
</AppContainer>
<Router>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow">
<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 />} />
</Routes>
</main>
<Footer />
</div>
</Router>
);
};

View file

@ -0,0 +1,49 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
const AboutUs: React.FC = () => {
return (
<div className="container mx-auto px-4 py-8">
<motion.h1
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-4xl font-bold text-[#8C1D40] mb-8"
>
About MCDi
</motion.h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<AboutSection
title="Meet MCDi Board"
description="Get to know the dedicated individuals leading our organization."
link="/meet-board"
/>
<AboutSection
title="Bylaws"
description="Learn about the rules and regulations governing MCDi."
link="/bylaws"
/>
<AboutSection
title="Minutes"
description="Access records of our meetings and decisions."
link="/minutes"
/>
</div>
</div>
);
};
const AboutSection: React.FC<{ title: string; description: string; link: string }> = ({ title, description, link }) => (
<motion.div
whileHover={{ scale: 1.05 }}
className="bg-white rounded-lg shadow-md p-6"
>
<h2 className="text-2xl font-semibold text-[#8C1D40] mb-4">{title}</h2>
<p className="text-gray-700 mb-4">{description}</p>
<Link to={link} className="text-[#8C1D40] font-bold hover:underline">
Learn More
</Link>
</motion.div>
);
export default AboutUs;

View file

@ -0,0 +1,32 @@
import React from 'react';
import { motion } from 'framer-motion';
const Bylaws: React.FC = () => {
return (
<div className="container mx-auto px-4 py-8">
<motion.h1
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-4xl font-bold text-[#8C1D40] mb-8"
>
MCDi Bylaws
</motion.h1>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="bg-white rounded-lg shadow-lg p-4"
>
<div className="w-full" style={{ height: '800px' }}>
<iframe
src="/MCD-Inc._AOI_Bylaws.pdf#view=FitH"
className="w-full h-full rounded-md"
title="MCDi Bylaws"
/>
</div>
</motion.div>
</div>
);
};
export default Bylaws;

View file

@ -0,0 +1,175 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import EventCard from './EventCard';
import { parseISO, startOfDay, isSameDay, isAfter, format } from 'date-fns';
import { events, Event } from '../eventData';
const formatTime = (dateString: string, time: string): string => {
const date = parseISO(dateString);
const [hours, minutes] = time.split(':');
const eventDate = new Date(date.setHours(parseInt(hours, 10), parseInt(minutes, 10)));
return format(eventDate, 'MMMM d, yyyy h:mm a');
};
const EventPopup: React.FC<{ event: Event; position: { top: number; left: number } }> = ({ event, position }) => (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="absolute z-10 bg-white p-4 rounded-lg shadow-lg"
style={{ top: position.top, left: position.left }}
>
<h3 className="font-bold text-lg">{event.title}</h3>
<p>{formatTime(event.date, event.startTime)} - {formatTime(event.date, event.endTime)}</p>
<p>{event.description}</p>
<p>Contact: <a href={`mailto:${event.email}`} className="text-blue-500 hover:underline">{event.pointOfContact}</a></p>
</motion.div>
);
const Calendar: React.FC = () => {
const [currentMonth, setCurrentMonth] = useState(new Date(2024, 10, 1));
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
const [isPopupClosing, setIsPopupClosing] = useState(false);
const calendarRef = useRef<HTMLDivElement>(null);
const isEventUpcoming = (eventDate: string) => {
const today = startOfDay(new Date());
const parsedEventDate = startOfDay(parseISO(eventDate));
return isSameDay(parsedEventDate, today) || isAfter(parsedEventDate, today);
};
const daysInMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0).getDate();
const firstDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1).getDay();
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const emptyDays = Array.from({ length: firstDayOfMonth }, (_, i) => i);
const monthNames = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
const nextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
};
const prevMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (calendarRef.current && !calendarRef.current.contains(event.target as Node)) {
setSelectedEvent(null);
} else if (selectedEvent) {
setSelectedEvent(null);
setIsPopupClosing(true);
setTimeout(() => setIsPopupClosing(false), 0);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [selectedEvent]);
const handleEventClick = (event: Event, e: React.MouseEvent) => {
if (!isPopupClosing) {
const rect = (e.target as HTMLElement).getBoundingClientRect();
setPopupPosition({ top: rect.bottom, left: rect.left });
setSelectedEvent(event);
}
};
const upcomingEvents = events
.filter(event => isEventUpcoming(event.date))
.sort((a, b) => parseISO(a.date).getTime() - parseISO(b.date).getTime());
return (
<div className="max-w-4xl mx-auto p-6 relative" ref={calendarRef}>
<div className="flex justify-between items-center mb-6">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="text-2xl text-[#8C1D40]"
onClick={prevMonth}
>
</motion.button>
<h2 className="text-2xl font-bold text-[#8C1D40]">
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
</h2>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="text-2xl text-[#8C1D40]"
onClick={nextMonth}
>
</motion.button>
</div>
<div className="grid grid-cols-7 gap-1 bg-gray-200 rounded-lg overflow-hidden">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
<div key={day} className="bg-[#8C1D40] text-white font-bold p-2 text-center">
{day}
</div>
))}
{emptyDays.map(day => (
<div key={`empty-${day}`} className="bg-white p-2 h-24"></div>
))}
{days.map(day => {
const currentDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day);
const dateString = format(currentDate, 'yyyy-MM-dd');
const dayEvents = events.filter(event => event.date === dateString);
return (
<motion.div
key={day}
className="bg-white p-2 h-24 cursor-pointer flex flex-col items-start justify-start overflow-hidden"
whileHover={{ backgroundColor: '#FFCCCB' }}
>
<span className="font-semibold">{day}</span>
{dayEvents.map((event, index) => (
<div
key={index}
className="text-xs bg-[#8C1D40] text-white p-1 mt-1 rounded w-full"
onClick={(e) => handleEventClick(event, e)}
>
<div>{event.title}</div>
<div>{`${formatTime(event.date, event.startTime).split(' ')[3]} - ${formatTime(event.date, event.endTime).split(' ')[3]}`}</div>
</div>
))}
</motion.div>
);
})}
</div>
<AnimatePresence>
{selectedEvent && (
<EventPopup
event={selectedEvent}
position={popupPosition}
/>
)}
</AnimatePresence>
<div className="mt-12">
<h2 className="text-2xl font-bold text-[#8C1D40] mb-6">Upcoming Events</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 justify-items-center">
{upcomingEvents.map((event, index) => (
<EventCard
key={index}
event={{
...event,
formattedStartTime: formatTime(event.date, event.startTime),
formattedEndTime: formatTime(event.date, event.endTime)
}}
/>
))}
</div>
</div>
</div>
);
};
export default Calendar;

View file

@ -1,31 +1,20 @@
import React from 'react';
import styled from 'styled-components';
import { motion } from 'framer-motion';
const ComingSoonContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 60vh;
`;
const ComingSoonText = styled(motion.h2)`
font-size: 3rem;
color: #8C1D40;
`;
const ComingSoon: React.FC = () => {
return (
<ComingSoonContainer>
<ComingSoonText
<div className="flex items-center justify-center h-screen bg-gradient-to-r from-[#8C1D40] to-[#6B1631]">
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1 }}
className="text-center"
>
COMING SOON
</ComingSoonText>
</ComingSoonContainer>
<h1 className="text-6xl font-bold text-white mb-4">COMING SOON</h1>
<p className="text-xl text-white">We're working hard to bring you something amazing. Stay tuned!</p>
</motion.div>
</div>
);
};
export default ComingSoon;
export default ComingSoon;

View file

@ -0,0 +1,38 @@
import React from 'react';
import { motion } from 'framer-motion';
import { format } from 'date-fns';
interface EventCardProps {
event: {
date: string;
title: string;
description: string;
formattedStartTime: string;
formattedEndTime: string;
pointOfContact: string;
email: string;
};
}
const EventCard: React.FC<EventCardProps> = ({ event }) => {
const startDate = new Date(event.formattedStartTime);
const endDate = new Date(event.formattedEndTime);
const formattedDate = format(startDate, 'MMMM d, yyyy');
const formattedTime = `${format(startDate, 'h:mm a')} - ${format(endDate, 'h:mm a')}`;
return (
<motion.div
whileHover={{ scale: 1.05 }}
className="bg-white rounded-lg shadow-md p-4 w-full max-w-xs"
>
<h3 className="font-bold text-lg text-[#8C1D40] mb-2">{event.title}</h3>
<p className="text-sm text-gray-600 mb-1">{formattedDate}</p>
<p className="text-sm mb-2">{formattedTime}</p>
<p className="text-sm mb-2">{event.description}</p>
<p className="text-sm">Contact: <a href={`mailto:${event.email}`} className="text-blue-500 hover:underline">{event.pointOfContact}</a></p>
</motion.div>
);
};
export default EventCard;

View file

@ -1,21 +1,201 @@
import React from 'react';
import styled from 'styled-components';
const FooterContainer = styled.footer`
background-color: #8C1D40;
color: white;
text-align: center;
padding: 1rem;
position: absolute;
bottom: 0;
width: 100%;
`;
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
const Footer: React.FC = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: '',
honeypot: '', // Honeypot field
});
const [status, setStatus] = useState('');
const [errors, setErrors] = useState<Partial<typeof formData>>({});
const [lastSubmissionTime, setLastSubmissionTime] = useState(0);
useEffect(() => {
const storedTime = localStorage.getItem('lastSubmissionTime');
if (storedTime) {
setLastSubmissionTime(parseInt(storedTime, 10));
}
}, []);
const validateForm = (data: typeof formData) => {
const errors: Partial<typeof formData> = {};
if (!data.name.trim()) errors.name = "Name is required";
if (!data.email.trim()) errors.email = "Email is required";
else if (!/\S+@\S+\.\S+/.test(data.email)) errors.email = "Email is invalid";
if (!data.subject.trim()) errors.subject = "Subject is required";
if (!data.message.trim()) errors.message = "Message is required";
return errors;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData({ ...formData, [name]: value });
setErrors({ ...errors, [name]: '' });
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
setStatus('Please correct the errors in the form.');
return;
}
// Check honeypot field
if (formData.honeypot) {
setStatus('Form submission rejected.');
return;
}
// Rate limiting
const currentTime = Date.now();
if (currentTime - lastSubmissionTime < 60000) { // 1 minute cooldown
setStatus('Please wait a moment before submitting again.');
return;
}
setStatus('Sending...');
try {
const response = await fetch('/api/sendEmail', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
const data = await response.json();
if (response.ok) {
setStatus('Message sent successfully!');
setFormData({ name: '', email: '', subject: '', message: '', honeypot: '' });
setLastSubmissionTime(currentTime);
localStorage.setItem('lastSubmissionTime', currentTime.toString());
} else {
setStatus(`Failed to send message: ${data.error || 'Unknown error'}`);
}
} else {
setStatus('Received non-JSON response from server');
}
} catch (error: unknown) {
console.error('Error sending message:', error);
setStatus(`An error occurred: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
return (
<FooterContainer>
<p>&copy; MCDi 2024</p>
</FooterContainer>
<footer className="bg-[#8C1D40] text-white py-8">
<div className="container mx-auto px-4">
<div className="flex flex-wrap justify-between">
<div className="w-full md:w-1/3 mb-6 md:mb-0">
<h3 className="text-xl font-bold mb-4">MCDi</h3>
<p className="text-sm">Missoula Council of the Deaf, Inc.</p>
<p className="text-sm mt-2">© MCDi {new Date().getFullYear()}</p>
<a
href="https://www.facebook.com/profile.php?id=61564416121195"
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-4 transition-transform duration-300 ease-in-out transform hover:scale-110"
>
<img
src="/facebook-logo.png"
alt="Facebook"
className="w-10 h-10 rounded-full shadow-lg hover:shadow-xl"
style={{
filter: 'drop-shadow(0px 4px 6px rgba(0, 0, 0, 0.1))',
}}
/>
</a>
</div>
<div className="w-full md:w-1/3 mb-6 md:mb-0">
<h3 className="text-xl font-bold mb-4">Quick Links</h3>
<ul className="text-sm">
<li className="mb-2"><Link to="/" className="hover:text-yellow-200 transition-colors duration-200">Home</Link></li>
<li className="mb-2"><Link to="/news" className="hover:text-yellow-200 transition-colors duration-200">News</Link></li>
<li className="mb-2"><Link to="/calendar" className="hover:text-yellow-200 transition-colors duration-200">Calendar</Link></li>
<li className="mb-2"><Link to="/bylaws" className="hover:text-yellow-200 transition-colors duration-200">Bylaws</Link></li>
<li className="mb-2"><Link to="/sponsors" className="hover:text-yellow-200 transition-colors duration-200">Sponsors</Link></li>
<li className="mb-2"><Link to="/meet-board" className="hover:text-yellow-200 transition-colors duration-200">Meet MCDi Board</Link></li>
</ul>
</div>
<div className="w-full md:w-1/3">
<h3 className="text-xl font-bold mb-4">Contact Us</h3>
<form className="text-sm" onSubmit={handleSubmit}>
<div className="mb-2">
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Name"
className={`w-full p-2 text-black rounded ${errors.name ? 'border-red-500' : ''}`}
required
/>
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
</div>
<div className="mb-2">
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Your Email Address"
className={`w-full p-2 text-black rounded ${errors.email ? 'border-red-500' : ''}`}
required
/>
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email}</p>}
</div>
<div className="mb-2">
<input
type="text"
name="subject"
value={formData.subject}
onChange={handleChange}
placeholder="Subject"
className={`w-full p-2 text-black rounded ${errors.subject ? 'border-red-500' : ''}`}
required
/>
{errors.subject && <p className="text-red-500 text-xs mt-1">{errors.subject}</p>}
</div>
<div className="mb-2">
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Message"
rows={3}
className={`w-full p-2 text-black rounded ${errors.message ? 'border-red-500' : ''}`}
required
></textarea>
{errors.message && <p className="text-red-500 text-xs mt-1">{errors.message}</p>}
</div>
<input
type="text"
name="honeypot"
value={formData.honeypot}
onChange={handleChange}
style={{display: 'none'}}
tabIndex={-1}
autoComplete="off"
/>
<button
type="submit"
className="bg-yellow-400 text-[#8C1D40] font-bold py-2 px-4 rounded hover:bg-yellow-300 transition-colors duration-200"
>
Send
</button>
{status && <p className="mt-2 text-sm">{status}</p>}
</form>
</div>
</div>
</div>
</footer>
);
};

View file

@ -1,61 +1,97 @@
import React from 'react';
import styled from 'styled-components';
import { motion } from 'framer-motion';
const HeaderContainer = styled.header`
background-color: #8C1D40;
color: white;
padding: 1rem;
`;
const Title = styled.h1`
font-size: 1.5rem;
text-align: right;
margin: 0;
`;
const Nav = styled.nav`
display: flex;
justify-content: center;
margin-top: 1rem;
`;
const NavItem = styled(motion.a)`
color: white;
text-decoration: none;
margin: 0 1rem;
position: relative;
&:after {
content: '';
position: absolute;
width: 100%;
height: 2px;
bottom: -5px;
left: 0;
background-color: white;
transform: scaleX(0);
transition: transform 0.3s ease-in-out;
}
&:hover:after {
transform: scaleX(1);
}
`;
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
const Header: React.FC = () => {
return (
<HeaderContainer>
<Title>Missoula Council of the Deaf, Inc</Title>
<Nav>
<NavItem href="/" whileHover={{ scale: 1.1 }}>Home</NavItem>
<NavItem href="/meet-board" whileHover={{ scale: 1.1 }}>Meet MCDi Board</NavItem>
<NavItem href="/MCD-Inc._AOI_Bylaws.pdf" whileHover={{ scale: 1.1 }}>Bylaws</NavItem>
<NavItem href="/news" whileHover={{ scale: 1.1 }}>News</NavItem>
<NavItem href="/calendar" whileHover={{ scale: 1.1 }}>Calendar</NavItem>
</Nav>
</HeaderContainer>
<header className="bg-[#8C1D40] text-white shadow-md">
<div className="container mx-auto px-4 py-4">
<div className="flex justify-between items-center">
<div className="flex items-center">
<img src="/mcdi-logo-small.png" alt="MCDi Logo" className="w-20 h-20 mr-4" />
<h1 className="text-2xl font-bold">Missoula Council of the Deaf, Inc</h1>
</div>
<nav>
<ul className="flex space-x-6 items-center">
<li><NavItem href="/">Home</NavItem></li>
<li><AboutDropdown /></li>
<li><NavItem href="/calendar">Calendar</NavItem></li>
<li><NavItem href="/joinmcdi">Join MCDi</NavItem></li>
<li><DonateButton /></li>
</ul>
</nav>
</div>
</div>
</header>
);
};
interface NavItemProps {
href: string;
children: React.ReactNode;
}
const NavItem: React.FC<NavItemProps> = ({ href, children }) => (
<Link to={href} className="hover:text-yellow-200 transition-colors duration-200">
<motion.span whileHover={{ scale: 1.05 }} className="inline-block">
{children}
</motion.span>
</Link>
);
const AboutDropdown: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div
className="relative"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<Link
to="/about"
className="hover:text-yellow-200 transition-colors duration-200"
>
<motion.span whileHover={{ scale: 1.05 }} className="inline-block">
About MCDi
</motion.span>
</Link>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute left-0 mt-2 w-48 bg-[#8C1D40] rounded-md shadow-lg z-10"
>
<div className="py-1">
<DropdownItem href="/meet-board">Meet MCDi Board</DropdownItem>
<DropdownItem href="/bylaws">Bylaws</DropdownItem>
<DropdownItem href="/minutes">Minutes</DropdownItem>
<DropdownItem href="/sponsors">Sponsors</DropdownItem>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
const DropdownItem: React.FC<NavItemProps> = ({ href, children }) => (
<Link to={href} className="block px-4 py-2 hover:bg-[#6B1631] transition-colors duration-200">
<motion.span whileHover={{ x: 5 }} className="inline-block">
{children}
</motion.span>
</Link>
);
const DonateButton: React.FC = () => (
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Link to="/donate" className="inline-block bg-yellow-400 text-[#8C1D40] font-bold py-2 px-4 rounded-full hover:bg-yellow-300 transition-colors duration-200 shadow-md">
Donate
</Link>
</motion.div>
);
export default Header;

View file

@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import ImageCarousel from './ImageCarousel';
import EventCard from './EventCard';
import { parseISO, startOfDay, isSameDay, isAfter, format } from 'date-fns';
import { events, Event } from '../eventData';
const formatTime = (dateString: string, time: string): string => {
const date = parseISO(dateString);
const [hours, minutes] = time.split(':');
const eventDate = new Date(date.setHours(parseInt(hours, 10), parseInt(minutes, 10)));
return format(eventDate, 'MMMM d, yyyy h:mm a');
};
const isEventUpcoming = (eventDate: string) => {
const today = startOfDay(new Date());
const parsedEventDate = startOfDay(parseISO(eventDate));
return isSameDay(parsedEventDate, today) || isAfter(parsedEventDate, today);
};
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);
}, []);
return (
<div className="container mx-auto px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-4xl font-bold text-[#8C1D40] mb-8">Welcome to MCDi</h1>
<ImageCarousel />
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div className="grid grid-rows-2 gap-8">
<InfoCard
title="Empowering Deaf Culture and Community"
content="We're proud to be the Missoula Council of the Deaf, Inc. (MCDi) in the heart of Missoula, Montana. Our organization is dedicated to promoting Deaf culture, community, and systematic change in our beautiful state."
/>
<InfoCard
title="Deafhood: A Shining Example of Diversity"
content="Just as Montana's vast landscapes showcase breathtaking vistas, our Deaf community brings unique perspectives and experiences to the table. We're proud to celebrate Deaf culture, language, and traditions that enrich our human kaleidoscope."
/>
</div>
<div>
<h2 className="text-2xl font-semibold text-[#8C1D40] mb-4">Upcoming Events</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{upcomingEvents.map((event, index) => (
<EventCard
key={index}
event={{
...event,
formattedStartTime: formatTime(event.date, event.startTime),
formattedEndTime: formatTime(event.date, event.endTime)
}}
/>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<InfoCard
title="Join Us: Support Change in Montana"
content="As a parent or ally, you can make a difference by joining MCDi. Together, we'll work toward creating a more inclusive environment for all Montanans, regardless of communication style. Your support will help shape the future of Deaf education, employment, and accessibility in our great state."
/>
<InfoCard
title="Get Involved: Explore, Protect, and Give Back"
content="MCDi is dedicated to protecting and preserving Deaf culture and rights. Join us for events, workshops, and advocacy opportunities that will inspire you to make a difference."
/>
<InfoCard
title="Join the Journey: Together We Can Make a Difference"
content="As we explore, protect, and give back to our community, remember that every individual brings unique strengths and perspectives. Let's work together to create a brighter future for all Montanans, where Deaf culture is celebrated and valued."
/>
</div>
</motion.div>
</div>
);
};
const InfoCard: React.FC<{ title: string; content: string }> = ({ title, content }) => (
<motion.div
className="bg-white rounded-lg shadow-md p-6 flex flex-col h-full"
whileHover={{ scale: 1.03 }}
transition={{ duration: 0.2 }}
>
<h2 className="text-2xl font-semibold text-[#8C1D40] mb-4">{title}</h2>
<p className="text-gray-700 flex-grow">{content}</p>
</motion.div>
);
export default Home;

View file

@ -0,0 +1,35 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
const images = ['/photos/1.jpg', '/photos/2.jpg', '/photos/3.jpg', '/photos/4.jpg'];
const ImageCarousel: React.FC = () => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length);
}, 10000);
return () => clearInterval(timer);
}, []);
return (
<div className="relative w-full h-[calc(100vw*9/16)] max-h-[720px] overflow-hidden mb-8 border-4 border-[#8C1D40] rounded-lg shadow-lg">
<AnimatePresence initial={false}>
<motion.img
key={currentIndex}
src={images[currentIndex]}
alt={`Slide ${currentIndex + 1}`}
className="absolute w-full h-full object-cover"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ duration: 0.5 }}
/>
</AnimatePresence>
</div>
);
};
export default ImageCarousel;

View file

@ -0,0 +1,225 @@
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaPlay, FaPause, FaStop } from 'react-icons/fa';
interface BoardMember {
name: string;
position: string;
email: string;
image: string;
bio: string;
}
const boardMembers: BoardMember[] = [
{
name: "Eliza Kragh",
position: "President",
email: "eliza.kragh@deafmissoula.org",
image: "/headshots/female.png",
bio: "Eliza Kragh is a devoted advocate for Deaf rights and a passionate outdoor enthusiast. When she's not paddling through serene lakes or hiking through untamed wilderness with her trusty sidekick Khaja, you can find Eliza pushing the boundaries of inclusivity and accessibility. Her dry wit and introspective nature often catch people off guard, but one thing is clear: Eliza leads by example, inspiring others to stand up for what's right. With a heart full of conviction and a spirit that refuses to be silenced, she's a true champion of Deaf excellence."
},
{
name: "Rita Brandborg",
position: "Vice President",
email: "rita.brandborg@deafmissoula.org",
image: "/headshots/female.png",
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: "Aubz M",
position: "Secretary",
email: "aubz.m@deafmissoula.org",
image: "/headshots/female.png",
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!"
},
{
name: "Kevin Cooley",
position: "Treasurer",
email: "kevin.cooley@deafmissoula.org",
image: "/headshots/male.png",
bio: "Kevin Cooley is a force to be reckoned with in the Deaf community. When he's not digging up dirt (literally) as an excavator operator, you can find him reeling in trout or decorating his pad with skulls (because why not?). Halloween? The holiday of all holidays! Kevin loves living life on his own terms and making those around him smile. With a heart of gold and a mind sharp enough to cut through any situation, he's the kind of friend who'll be there for you no matter what."
},
{
name: "Tessa Williams",
position: "Event Coordinator",
email: "tessa.williams@deafmissoula.org",
image: "/headshots/female.png",
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."
}
];
const shuffleArray = (array: BoardMember[]) => {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
const MeetBoard: React.FC = () => {
const [selectedMember, setSelectedMember] = useState<BoardMember | null>(null);
const [shuffledMembers, setShuffledMembers] = useState<BoardMember[]>([]);
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [showControls, setShowControls] = useState(true);
const [progress, setProgress] = useState(0);
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setShuffledMembers(shuffleArray(boardMembers));
}, []);
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleStop = () => {
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.currentTime = 0;
setIsPlaying(false);
}
};
const handleTimeUpdate = () => {
if (videoRef.current) {
const progress = (videoRef.current.currentTime / videoRef.current.duration) * 100;
setProgress(progress);
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
if (videoRef.current) {
const seekTime = (parseFloat(e.target.value) / 100) * videoRef.current.duration;
videoRef.current.currentTime = seekTime;
}
};
const handleMouseMove = () => {
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
if (isPlaying) {
setShowControls(false);
}
}, 3000);
};
return (
<div className="container mx-auto px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-4xl font-bold text-[#8C1D40] mb-8">Meet MCDi Board</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{shuffledMembers.slice(0, 3).map((member, index) => (
<BoardMemberCard key={index} member={member} onClick={() => setSelectedMember(member)} />
))}
</div>
<div className="mt-8 flex justify-center gap-8">
{shuffledMembers.slice(3, 5).map((member, index) => (
<BoardMemberCard key={index + 3} member={member} onClick={() => setSelectedMember(member)} />
))}
</div>
<div className="mt-12">
<div
className="relative rounded-lg overflow-hidden shadow-lg border-4 border-[#8C1D40]"
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
<video
ref={videoRef}
className="w-full"
onTimeUpdate={handleTimeUpdate}
onEnded={() => setIsPlaying(false)}
>
<source src="/videos/mcdi-intro.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
<AnimatePresence>
{showControls && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 p-4"
>
<div className="flex items-center justify-between">
<button onClick={handleStop} className="text-white mr-2">
<FaStop />
</button>
<button onClick={togglePlay} className="text-white mr-2">
{isPlaying ? <FaPause /> : <FaPlay />}
</button>
<input
type="range"
min="0"
max="100"
value={progress}
onChange={handleSeek}
className="w-full mx-2"
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
<AnimatePresence>
{selectedMember && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
onClick={() => setSelectedMember(null)}
>
<motion.div
initial={{ y: -50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -50, opacity: 0 }}
className="bg-white rounded-lg p-8 max-w-md"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-2xl font-semibold text-[#8C1D40] mb-4">{selectedMember.name}</h2>
<p className="text-lg text-gray-700 mb-2">{selectedMember.position}</p>
<p className="text-gray-600 mb-4">{selectedMember.bio}</p>
<a href={`mailto:${selectedMember.email}`} className="text-blue-500 hover:underline">{selectedMember.email}</a>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
const BoardMemberCard: React.FC<{ member: BoardMember; onClick: () => void }> = ({ member, onClick }) => (
<motion.div
className="bg-white rounded-lg shadow-md p-6 flex flex-col items-center cursor-pointer"
whileHover={{ scale: 1.05 }}
transition={{ duration: 0.2 }}
onClick={onClick}
>
<h2 className="text-2xl font-semibold text-[#8C1D40] mb-4">{member.name}</h2>
<img src={member.image} alt={member.name} className="w-48 h-48 object-cover rounded-full mb-4" />
<p className="text-lg text-gray-700">{member.position}</p>
</motion.div>
);
export default MeetBoard;

View file

@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
interface MinuteEntry {
date: string;
meetingType: string;
location: string;
fileUrl: string;
}
const minutesData: MinuteEntry[] = [
{
date: '08/06/2024',
meetingType: 'Membership Meeting',
location: 'The Break Espresso',
fileUrl: '/minutes/minutes-08062024.pdf',
},
// Add more entries here as needed
];
const Minutes: React.FC = () => {
const [sortColumn, setSortColumn] = useState<keyof MinuteEntry>('date');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const sortedMinutes = [...minutesData].sort((a, b) => {
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) => {
if (column === sortColumn) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
return (
<div className="container mx-auto px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-3xl font-bold text-[#8C1D40] mb-6">Meeting Minutes</h1>
<div className="overflow-x-auto">
<table className="w-full bg-white shadow-md rounded-lg overflow-hidden">
<thead className="bg-[#8C1D40] text-white">
<tr>
<th className="py-3 px-4 text-left cursor-pointer hover:bg-[#6B1631]" onClick={() => handleSort('date')}>
Date {sortColumn === 'date' && <span className="ml-1">{sortDirection === 'asc' ? '▲' : '▼'}</span>}
</th>
<th className="py-3 px-4 text-left cursor-pointer hover:bg-[#6B1631]" onClick={() => handleSort('meetingType')}>
Meeting Type {sortColumn === 'meetingType' && <span className="ml-1">{sortDirection === 'asc' ? '▲' : '▼'}</span>}
</th>
<th className="py-3 px-4 text-left cursor-pointer hover:bg-[#6B1631]" onClick={() => handleSort('location')}>
Location {sortColumn === 'location' && <span className="ml-1">{sortDirection === 'asc' ? '▲' : '▼'}</span>}
</th>
<th className="py-3 px-4 text-left">Minutes</th>
</tr>
</thead>
<tbody>
{sortedMinutes.map((entry, index) => ( <tr key={index} className="border-b border-gray-200 hover:bg-gray-100">
<td className="py-3 px-4">{entry.date}</td>
<td className="py-3 px-4">{entry.meetingType}</td>
<td className="py-3 px-4">{entry.location}</td>
<td className="py-3 px-4">
<a
href={entry.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
View Minutes
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
</div>
);
};
export default Minutes;

View file

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
interface SponsorCardProps {
name: string;
logoPath: string;
description: string;
onClick: () => void;
}
const SponsorCard: React.FC<SponsorCardProps> = ({ name, logoPath, onClick }) => (
<motion.div
className="bg-white rounded-lg shadow-md p-6 flex flex-col items-center h-full cursor-pointer"
whileHover={{ scale: 1.03 }}
transition={{ duration: 0.2 }}
onClick={onClick}
>
<h2 className="text-2xl font-semibold text-[#8C1D40] mb-4">{name}</h2>
<div className="w-full h-48 flex items-center justify-center">
<img src={logoPath} alt={`${name} logo`} className="max-w-full max-h-full object-contain" />
</div>
</motion.div>
);
const Popup: React.FC<{ isOpen: boolean; onClose: () => void; children: React.ReactNode }> = ({ isOpen, onClose, children }) => (
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="bg-white rounded-lg p-6 max-w-md"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
>
{children}
<button className="mt-4 bg-[#8C1D40] text-white px-4 py-2 rounded" onClick={onClose}>Close</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
const Sponsors: React.FC = () => {
const [selectedSponsor, setSelectedSponsor] = useState<string | null>(null);
const sponsorInfo = {
"Drum Coffee": "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.",
"Imagine Nation Brewing": "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.",
"Gild Brewing": "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.",
};
return (
<div className="container mx-auto px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-4xl font-bold text-[#8C1D40] mb-8">Our Sponsors</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<SponsorCard
name="Drum Coffee"
logoPath="/sponsors/drum-coffee-missoula.png"
description={sponsorInfo["Drum Coffee"]}
onClick={() => setSelectedSponsor("Drum Coffee")}
/>
<SponsorCard
name="Imagine Nation Brewing"
logoPath="/sponsors/imagine-nation-brewing.png"
description={sponsorInfo["Imagine Nation Brewing"]}
onClick={() => setSelectedSponsor("Imagine Nation Brewing")}
/>
</div>
<div className="mt-8 flex justify-center">
<div className="w-full md:w-1/2">
<SponsorCard
name="Gild Brewing"
logoPath="/sponsors/gild-brewing.png"
description={sponsorInfo["Gild Brewing"]}
onClick={() => setSelectedSponsor("Gild Brewing")}
/>
</div>
</div>
</motion.div>
<Popup isOpen={!!selectedSponsor} onClose={() => setSelectedSponsor(null)}>
{selectedSponsor && (
<>
<h2 className="text-2xl font-bold mb-4">{selectedSponsor}</h2>
<p>{sponsorInfo[selectedSponsor as keyof typeof sponsorInfo]}</p>
</>
)}
</Popup>
</div>
);
};
export default Sponsors;

66
client/src/eventData.ts Normal file
View file

@ -0,0 +1,66 @@
export interface Event {
date: string;
title: string;
description: string;
startTime: string;
endTime: string;
pointOfContact: string;
email: string;
}
export const events: Event[] = [
{
date: '2024-11-01',
title: 'ASL Night Out!',
description: 'Cornhole Tournament - will have 3 rows of cornholes and will do elimination for winner',
startTime: '18:00',
endTime: '20:00',
pointOfContact: 'Tessa Williams',
email: 'tessa.williams@deafmissoula.org'
},
{
date: '2024-11-02',
title: 'Missoula Concealed Weapons Class',
description: 'Concealed weapons course at Cabela\'s',
startTime: '10:00',
endTime: '14:00',
pointOfContact: 'Kevin Cooley',
email: 'kevin.cooley@deafmissoula.org'
},
{
date: '2024-11-28',
title: 'Thanksgiving Volunteer',
description: 'Volunteer at Missoula Food Bank and Community Center with us',
startTime: '10:00',
endTime: '13:00',
pointOfContact: 'Aubz M',
email: 'aubz.m@deafmissoula.org'
},
{
date: '2024-12-06',
title: 'ASL Black Tie and Gown',
description: 'Time to dress up in your finest clothes for a night of dancing and rocking the fabulous looks',
startTime: '19:00',
endTime: '23:00',
pointOfContact: 'Rita Brandborg',
email: 'rita.brandborg@deafmissoula.org'
},
{
date: '2024-12-24',
title: 'Christmas Cheer Volunteer',
description: 'Volunteer at Missoula Food Bank and Community Center',
startTime: '10:00',
endTime: '12:00',
pointOfContact: 'Tessa Williams',
email: 'tessa.williams@deafmissoula.org'
},
{
date: '2025-01-11',
title: 'MCDi Members Meeting',
description: 'Black Roasting Coffee Company',
startTime: '10:00',
endTime: '11:00',
pointOfContact: 'Eliza Kragh',
email: 'eliza.kragh@deafmissoula.org'
}
];

5
client/src/index.css Normal file
View file

@ -0,0 +1,5 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* You can add any custom styles here */

11
client/src/index.js Normal file
View file

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import './tailwind.css';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

10
client/tailwind.config.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
"./public/index.html",
],
theme: {
extend: {},
},
plugins: [],
}

View file

@ -1,8 +1,12 @@
version: '3'
services:
app:
build: .
build:
context: .
dockerfile: Dockerfile
ports:
- "5000:5000"
- "4000:5000"
environment:
- NODE_ENV=production
- NODE_ENV=production
env_file:
- .env

BIN
server/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -1,20 +1,29 @@
{
"name": "mcdi-website-server",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts"
},
"dependencies": {
"express": "^4.17.1",
"mongoose": "^6.0.12"
},
"devDependencies": {
"@types/express": "^4.17.13",
"@types/node": "^16.11.6",
"ts-node-dev": "^1.1.8",
"typescript": "^4.4.4"
}
}
"name": "mcdi-website-server",
"version": "1.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"
},
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "tsc",
"postinstall": "npm run build"
}
}

View file

@ -0,0 +1,37 @@
import { Request, Response } from 'express';
import nodemailer from 'nodemailer';
import dotenv from 'dotenv';
dotenv.config();
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.GOOGLE_EMAIL,
pass: process.env.GOOGLE_APP_PASSWORD,
},
});
export default async function handler(req: Request, res: Response) {
if (req.method === 'POST') {
const { name, email, subject, message } = req.body;
const mailOptions = {
from: process.env.GOOGLE_EMAIL,
to: 'contact@deafmissoula.org',
subject: `MCDi Contact Form: ${subject}`,
text: `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`,
};
try {
await transporter.sendMail(mailOptions);
res.status(200).json({ message: 'Email sent successfully' });
} catch (error) {
console.error('Detailed error sending email:', error);
res.status(500).json({ message: 'Error sending email', error: error instanceof Error ? error.message : 'Unknown error' });
}
} else {
res.setHeader('Allow', ['POST']);
res.status(405).json({ message: `Method ${req.method} Not Allowed` });
}
}

View file

@ -1,17 +1,41 @@
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';
const app = express();
const PORT = process.env.PORT || 5000;
const port = process.env.PORT || 5000;
app.use(cors());
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, '../../client/build')));
console.log('Serving static files from:', path.join(__dirname, '../../client/build'));
app.post('/api/sendEmail', sendEmail);
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' });
}
});
// Serve React app for all other routes
app.get('*', (req, res) => {
console.log('Serving index.html for path:', req.path);
res.sendFile(path.join(__dirname, '../../client/build/index.html'));
});
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});

View file

@ -1,11 +1,13 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}