Fresh update
This commit is contained in:
parent
aea0071b3b
commit
2521489cad
49 changed files with 1588 additions and 212 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal 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
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
.env
|
||||||
64
Dockerfile
64
Dockerfile
|
|
@ -1,26 +1,54 @@
|
||||||
# Build stage for client
|
# Build stage
|
||||||
FROM node:14 AS client-build
|
FROM node:14 AS build
|
||||||
WORKDIR /app/client
|
|
||||||
COPY client/package*.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY client/ ./
|
|
||||||
RUN npm run build -- --mode production
|
|
||||||
|
|
||||||
# Build stage for server
|
WORKDIR /app
|
||||||
FROM node:14 AS server-build
|
|
||||||
WORKDIR /app/server
|
# Copy package.json and package-lock.json
|
||||||
COPY server/package*.json ./
|
COPY .env ./.env
|
||||||
RUN npm install
|
COPY package*.json ./
|
||||||
COPY server/ ./
|
COPY client/package*.json ./client/
|
||||||
RUN npm run build
|
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
|
# Production stage
|
||||||
FROM node:14-alpine
|
FROM node:14-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=client-build /app/client/build ./client/build
|
|
||||||
COPY --from=server-build /app/server/dist ./server/dist
|
# Copy built assets from build stage
|
||||||
COPY --from=server-build /app/server/package*.json ./server/
|
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
|
WORKDIR /app/server
|
||||||
|
|
||||||
|
# Install production dependencies
|
||||||
RUN npm install --only=production
|
RUN npm install --only=production
|
||||||
|
|
||||||
|
# Expose port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
|
# Start the server
|
||||||
|
CMD ["npm", "start"]
|
||||||
79
README.md
79
README.md
|
|
@ -1,22 +1,38 @@
|
||||||
# Missoula Council of the Deaf, Inc. Website
|
# 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
|
## Features
|
||||||
|
|
||||||
- Responsive design
|
- Responsive design using Tailwind CSS
|
||||||
- Animated UI components using Framer Motion
|
- Dynamic UI components with React and Framer Motion for animations
|
||||||
- Docker containerization for easy deployment
|
- TypeScript for enhanced type safety and developer experience
|
||||||
- Express.js backend serving static React files
|
- 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
|
- `App.tsx`: Main application component with routing setup
|
||||||
- `server/`: Express.js backend application
|
- `Header.tsx`: Navigation component
|
||||||
- `Dockerfile`: Multi-stage build for both client and server
|
- `Home.tsx`: Landing page with featured content
|
||||||
- `docker-compose.yml`: Docker Compose configuration for running the application
|
- `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
|
## Getting Started
|
||||||
|
|
||||||
|
|
@ -24,5 +40,42 @@ To run this project locally:
|
||||||
|
|
||||||
1. Clone the repository
|
1. Clone the repository
|
||||||
2. Install Docker and Docker Compose
|
2. Install Docker and Docker Compose
|
||||||
3. Run `docker-compose up --build` in the project root directory
|
3. Create a `.env` file in the root directory with necessary environment variables
|
||||||
4. Access the website at `http://localhost:5000`
|
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.
|
||||||
|
|
|
||||||
2
Scheduled Events (Responses) - Form Responses 1.csv
Normal file
2
Scheduled Events (Responses) - Form Responses 1.csv
Normal 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
|
||||||
|
BIN
client/.DS_Store
vendored
Normal file
BIN
client/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -1,44 +1,53 @@
|
||||||
{
|
{
|
||||||
"name": "mcdi-website-client",
|
"name": "mcdi-website-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": ".",
|
"dependencies": {
|
||||||
"dependencies": {
|
"@types/node": "^20.8.9",
|
||||||
"react": "^18.2.0",
|
"@types/react": "^18.2.33",
|
||||||
"react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.14",
|
||||||
"react-router-dom": "^6.10.0",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"styled-components": "^5.3.9",
|
"framer-motion": "^10.16.4",
|
||||||
"framer-motion": "^10.12.4"
|
"react": "^18.2.0",
|
||||||
},
|
"react-dom": "^18.2.0",
|
||||||
"devDependencies": {
|
"react-router-dom": "^6.17.0",
|
||||||
"@types/react": "^18.0.37",
|
"react-scripts": "5.0.1",
|
||||||
"@types/react-dom": "^18.0.11",
|
"styled-components": "^6.1.0",
|
||||||
"@types/styled-components": "^5.1.26",
|
"typescript": "^5.2.2"
|
||||||
"typescript": "^5.0.4",
|
},
|
||||||
"react-scripts": "5.0.1"
|
"scripts": {
|
||||||
},
|
"build:css": "postcss src/index.css -o src/tailwind.css",
|
||||||
"scripts": {
|
"start": "react-scripts start",
|
||||||
"start": "react-scripts start",
|
"build": "npm run build:css && react-scripts build",
|
||||||
"build": "GENERATE_SOURCEMAP=false react-scripts build",
|
"test": "react-scripts test",
|
||||||
"test": "react-scripts test",
|
"eject": "react-scripts eject"
|
||||||
"eject": "react-scripts eject"
|
},
|
||||||
},
|
"eslintConfig": {
|
||||||
"eslintConfig": {
|
"extends": [
|
||||||
"extends": [
|
"react-app",
|
||||||
"react-app",
|
"react-app/jest"
|
||||||
"react-app/jest"
|
]
|
||||||
]
|
},
|
||||||
},
|
"browserslist": {
|
||||||
"browserslist": {
|
"production": [
|
||||||
"production": [
|
">0.2%",
|
||||||
">0.2%",
|
"not dead",
|
||||||
"not dead",
|
"not op_mini all"
|
||||||
"not op_mini all"
|
],
|
||||||
],
|
"development": [
|
||||||
"development": [
|
"last 1 chrome version",
|
||||||
"last 1 chrome version",
|
"last 1 firefox version",
|
||||||
"last 1 firefox version",
|
"last 1 safari 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
6
client/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
require('tailwindcss'),
|
||||||
|
require('autoprefixer'),
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
client/public/.DS_Store
vendored
Normal file
BIN
client/public/.DS_Store
vendored
Normal file
Binary file not shown.
Binary file not shown.
3
client/public/facebook-logo.png
Normal file
3
client/public/facebook-logo.png
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2adfd474d91fd20c51084309ed000c1ae6cc7f5f70af14d375930f5a71301308
|
||||||
|
size 54771
|
||||||
3
client/public/headshots/female.png
Normal file
3
client/public/headshots/female.png
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:47c65b7dcc296d6db7e9b500b9aeb3fd6bd2b3d80e6dcb5e00b6383d8eec0a7c
|
||||||
|
size 22027
|
||||||
3
client/public/headshots/male.png
Normal file
3
client/public/headshots/male.png
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d13c582ed01aeabdceff0203d09ffc4aa589f6092173edec9770c580c82dad28
|
||||||
|
size 16293
|
||||||
|
|
@ -16,5 +16,6 @@
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<script src="/static/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
3
client/public/mcdi-logo-small.png
Normal file
3
client/public/mcdi-logo-small.png
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7a714ba95a0ee8851eb5402c350a36d593a450b3c0b24ee93cae426e47dd6a5b
|
||||||
|
size 143884
|
||||||
3
client/public/mcdi-logo.png
Normal file
3
client/public/mcdi-logo.png
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c445b01ceb93e20e37f10875a001f8520520846188fc3eff7090895ba44ddbc9
|
||||||
|
size 44554
|
||||||
3
client/public/minutes/minutes-08062024.pdf
Normal file
3
client/public/minutes/minutes-08062024.pdf
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1b412a6d87a08ff2b41a01f5a07f4beeccdcaeea5ec55c153240a70e85367c45
|
||||||
|
size 32364
|
||||||
3
client/public/photos/1.jpg
Normal file
3
client/public/photos/1.jpg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:99702c36cef60bee6edda6066a6620532e78806c826ab45a5c8dba26430c1482
|
||||||
|
size 346854
|
||||||
3
client/public/photos/2.jpg
Normal file
3
client/public/photos/2.jpg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8d82e4cbb4e13fda8a220de097f9a0ae878b51731fc7b9b97cdf72903697d623
|
||||||
|
size 637203
|
||||||
3
client/public/photos/3.jpg
Normal file
3
client/public/photos/3.jpg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:076c577be3510a6b0d4e7642eb87cdfae9060584e759babe64ea2171b8838fe0
|
||||||
|
size 306688
|
||||||
3
client/public/photos/4.jpg
Normal file
3
client/public/photos/4.jpg
Normal 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
BIN
client/public/sponsors/.DS_Store
vendored
Normal file
Binary file not shown.
3
client/public/sponsors/drum-coffee-missoula.png
Normal file
3
client/public/sponsors/drum-coffee-missoula.png
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:67ef119e8607077db7675d3c2610104d893ea5d85bc8a40f7050295e6155064b
|
||||||
|
size 150487
|
||||||
3
client/public/sponsors/gild-brewing.png
Normal file
3
client/public/sponsors/gild-brewing.png
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b5fdb9b6d517bc5888c56b296940b8b15bc33535c5542233b14ab33822cbfd34
|
||||||
|
size 547385
|
||||||
3
client/public/sponsors/imagine-nation-brewing.png
Normal file
3
client/public/sponsors/imagine-nation-brewing.png
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:db404ce802400b3ade2eceeef3dec9b5e322ef41ceb2862c66cfa38fc8f1f946
|
||||||
|
size 552504
|
||||||
|
|
@ -1,22 +1,34 @@
|
||||||
import React from 'react';
|
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 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 Footer from './components/Footer';
|
||||||
import ComingSoon from './components/ComingSoon';
|
import Bylaws from './components/Bylaws';
|
||||||
|
import Sponsors from './components/Sponsors';
|
||||||
const AppContainer = styled.div`
|
import MeetBoard from './components/Meet-Board';
|
||||||
min-height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
padding-bottom: 60px; // Height of the footer
|
|
||||||
`;
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<AppContainer>
|
<Router>
|
||||||
<Header />
|
<div className="flex flex-col min-h-screen">
|
||||||
<ComingSoon />
|
<Header />
|
||||||
<Footer />
|
<main className="flex-grow">
|
||||||
</AppContainer>
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
49
client/src/components/AboutUs.tsx
Normal file
49
client/src/components/AboutUs.tsx
Normal 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;
|
||||||
32
client/src/components/Bylaws.tsx
Normal file
32
client/src/components/Bylaws.tsx
Normal 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;
|
||||||
175
client/src/components/Calendar.tsx
Normal file
175
client/src/components/Calendar.tsx
Normal 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;
|
||||||
|
|
@ -1,31 +1,20 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
|
||||||
import { motion } from 'framer-motion';
|
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 = () => {
|
const ComingSoon: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<ComingSoonContainer>
|
<div className="flex items-center justify-center h-screen bg-gradient-to-r from-[#8C1D40] to-[#6B1631]">
|
||||||
<ComingSoonText
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -50 }}
|
initial={{ opacity: 0, y: -50 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 1 }}
|
transition={{ duration: 1 }}
|
||||||
|
className="text-center"
|
||||||
>
|
>
|
||||||
COMING SOON
|
<h1 className="text-6xl font-bold text-white mb-4">COMING SOON</h1>
|
||||||
</ComingSoonText>
|
<p className="text-xl text-white">We're working hard to bring you something amazing. Stay tuned!</p>
|
||||||
</ComingSoonContainer>
|
</motion.div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ComingSoon;
|
export default ComingSoon;
|
||||||
38
client/src/components/EventCard.tsx
Normal file
38
client/src/components/EventCard.tsx
Normal 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;
|
||||||
|
|
@ -1,21 +1,201 @@
|
||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import styled from 'styled-components';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const FooterContainer = styled.footer`
|
|
||||||
background-color: #8C1D40;
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
padding: 1rem;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Footer: React.FC = () => {
|
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 (
|
return (
|
||||||
<FooterContainer>
|
<footer className="bg-[#8C1D40] text-white py-8">
|
||||||
<p>© MCDi 2024</p>
|
<div className="container mx-auto px-4">
|
||||||
</FooterContainer>
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,97 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import { Link } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } 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);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<HeaderContainer>
|
<header className="bg-[#8C1D40] text-white shadow-md">
|
||||||
<Title>Missoula Council of the Deaf, Inc</Title>
|
<div className="container mx-auto px-4 py-4">
|
||||||
<Nav>
|
<div className="flex justify-between items-center">
|
||||||
<NavItem href="/" whileHover={{ scale: 1.1 }}>Home</NavItem>
|
<div className="flex items-center">
|
||||||
<NavItem href="/meet-board" whileHover={{ scale: 1.1 }}>Meet MCDi Board</NavItem>
|
<img src="/mcdi-logo-small.png" alt="MCDi Logo" className="w-20 h-20 mr-4" />
|
||||||
<NavItem href="/MCD-Inc._AOI_Bylaws.pdf" whileHover={{ scale: 1.1 }}>Bylaws</NavItem>
|
<h1 className="text-2xl font-bold">Missoula Council of the Deaf, Inc</h1>
|
||||||
<NavItem href="/news" whileHover={{ scale: 1.1 }}>News</NavItem>
|
</div>
|
||||||
<NavItem href="/calendar" whileHover={{ scale: 1.1 }}>Calendar</NavItem>
|
<nav>
|
||||||
</Nav>
|
<ul className="flex space-x-6 items-center">
|
||||||
</HeaderContainer>
|
<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;
|
export default Header;
|
||||||
100
client/src/components/Home.tsx
Normal file
100
client/src/components/Home.tsx
Normal 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;
|
||||||
35
client/src/components/ImageCarousel.tsx
Normal file
35
client/src/components/ImageCarousel.tsx
Normal 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;
|
||||||
225
client/src/components/Meet-Board.tsx
Normal file
225
client/src/components/Meet-Board.tsx
Normal 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;
|
||||||
89
client/src/components/Minutes.tsx
Normal file
89
client/src/components/Minutes.tsx
Normal 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;
|
||||||
106
client/src/components/Sponsors.tsx
Normal file
106
client/src/components/Sponsors.tsx
Normal 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
66
client/src/eventData.ts
Normal 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
5
client/src/index.css
Normal 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
11
client/src/index.js
Normal 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
10
client/tailwind.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./public/index.html",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "4000:5000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
BIN
server/.DS_Store
vendored
Normal file
BIN
server/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -1,20 +1,29 @@
|
||||||
{
|
{
|
||||||
"name": "mcdi-website-server",
|
"name": "mcdi-website-server",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "dist/index.js",
|
"private": true,
|
||||||
"scripts": {
|
"dependencies": {
|
||||||
"start": "node dist/index.js",
|
"express": "^4.18.2",
|
||||||
"build": "tsc",
|
"dotenv": "^16.3.1",
|
||||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts"
|
"mongoose": "^7.6.3",
|
||||||
},
|
"cors": "^2.8.5",
|
||||||
"dependencies": {
|
"body-parser": "^1.20.2",
|
||||||
"express": "^4.17.1",
|
"nodemailer": "^6.9.1"
|
||||||
"mongoose": "^6.0.12"
|
},
|
||||||
},
|
"devDependencies": {
|
||||||
"devDependencies": {
|
"@types/cors": "^2.8.12",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.20",
|
||||||
"@types/node": "^16.11.6",
|
"@types/node": "^20.8.9",
|
||||||
"ts-node-dev": "^1.1.8",
|
"@types/mongodb": "^4.0.7",
|
||||||
"typescript": "^4.4.4"
|
"@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"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/src/api/sendEmail.ts
Normal file
37
server/src/api/sendEmail.ts
Normal 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` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,41 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'path';
|
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 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')));
|
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) => {
|
app.get('*', (req, res) => {
|
||||||
console.log('Serving index.html for path:', req.path);
|
|
||||||
res.sendFile(path.join(__dirname, '../../client/build/index.html'));
|
res.sendFile(path.join(__dirname, '../../client/build/index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server is running on port ${PORT}`);
|
console.log(`Server is running on port ${port}`);
|
||||||
});
|
});
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es6",
|
"target": "es6",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true
|
"esModuleInterop": true,
|
||||||
},
|
"skipLibCheck": true,
|
||||||
"include": ["src/**/*"]
|
"forceConsistentCasingInFileNames": true
|
||||||
}
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue