Admin dashboard updates.
This commit is contained in:
parent
ae01874c0a
commit
92a1744030
21 changed files with 4059 additions and 460 deletions
|
|
@ -6,13 +6,13 @@
|
|||
- React Router DOM 7
|
||||
- Tailwind CSS 4.0 (CSS-first configuration with @theme directives, 182x faster builds)
|
||||
- Framer Motion 11
|
||||
- React Icons 5.4
|
||||
- React Simple Maps 4.0 (beta)
|
||||
- React Intersection Observer 9.13
|
||||
- React Icons 5.5.0
|
||||
- React Simple Maps 3.0.0
|
||||
- React Intersection Observer 9.16.0
|
||||
- Next.js 15.2.3 (includes security patches for CVE-2025-29927)
|
||||
|
||||
### Backend
|
||||
- Node.js with Express 4.21
|
||||
- Node.js with Express 4.18.2
|
||||
- TypeScript 5.8
|
||||
- Nodemailer 6.9 for email handling
|
||||
- CORS for cross-origin resource sharing
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.21.0",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
|
|
|||
|
|
@ -1,50 +1,51 @@
|
|||
# Active Context: OCD Website Implementation Phase 1
|
||||
# Active Context: OCD Website Implementation Phase 3-4 Transition
|
||||
|
||||
## What I'm Working On Now
|
||||
- Implemented Phase 1 of the OCD website based on the planned implementation
|
||||
- Created key public-facing pages (Home, About, Contact) with full accessibility
|
||||
- Set up core components like Navigation, Footer, Hero, and ContactForm with WCAG 2.2 AA compliance
|
||||
- Established MongoDB schema validation and database models
|
||||
- Implemented JWT-based authentication middleware with RBAC
|
||||
- Created Docker configuration with security best practices
|
||||
- Completing Phase 3 of the OCD website development plan
|
||||
- Finalizing admin interface implementation
|
||||
- Setting up connections between frontend UI and backend APIs
|
||||
- Preparing for Phase 4 (accessibility features and refinement)
|
||||
- Adding API integration for CRUD operations
|
||||
|
||||
## Recent Changes
|
||||
- Created the complete directory structure following atomic design principles
|
||||
- Set up core configuration files:
|
||||
- Docker Compose with multi-stage builds, resource limits, and security
|
||||
- Frontend/backend Dockerfiles with non-root users
|
||||
- MongoDB initialization scripts with schema validation
|
||||
- Next.js configuration with security headers
|
||||
- Tailwind CSS with CSS-first @theme configuration
|
||||
- Nginx configuration with security headers
|
||||
- Installed dependencies for both frontend and backend
|
||||
- Implemented responsive Navigation component with keyboard accessibility
|
||||
- Added Footer component with site map and accessibility options
|
||||
- Created Hero component with animations and responsive design
|
||||
- Built Contact form with validation and ARIA attributes
|
||||
- Implemented Home, About, and Contact pages with accessibility features
|
||||
- Created Video Player component with transcript display options
|
||||
- Set up JWT authentication middleware for admin dashboard
|
||||
- Implemented comprehensive admin dashboard system:
|
||||
- Created central dashboard with statistics and quick actions
|
||||
- Built out events management with filtering and CRUD UI
|
||||
- Implemented members management with bulk actions and status tracking
|
||||
- Developed video management with accessibility indicators
|
||||
- Added document repository with visibility controls
|
||||
- Created content management with WYSIWYG editor preview
|
||||
- Added settings interface with site, email, and membership options
|
||||
|
||||
- Major improvements to frontend architecture:
|
||||
- Implemented responsive layouts for all admin interfaces
|
||||
- Used TypeScript for strongly-typed components
|
||||
- Created reusable filter, table, and card components
|
||||
- Maintained consistent UI patterns across admin sections
|
||||
- Ensured all interfaces follow accessibility guidelines
|
||||
|
||||
## Next Steps
|
||||
1. Complete remaining public-facing pages:
|
||||
- Events page with calendar component
|
||||
- Membership information page
|
||||
- Donation page with Zeffy integration
|
||||
- Accessibility statement page
|
||||
2. Begin admin dashboard implementation:
|
||||
- Create authentication flow
|
||||
- Implement dashboard layout
|
||||
- Set up protected routes
|
||||
- Create admin forms for content management
|
||||
3. Implement event management system
|
||||
4. Set up video upload and management functionality
|
||||
5. Create membership tracking system with expiration notifications
|
||||
1. Complete backend API integrations:
|
||||
- Connect admin UI components to backend endpoints
|
||||
- Add real data loading with loading states
|
||||
- Implement error handling for API requests
|
||||
- Set up client-side data validation
|
||||
2. Implement remaining admin features:
|
||||
- Add form handlers for CRUD operations
|
||||
- Implement event recurrence functionality
|
||||
- Create media upload components
|
||||
- Add user notification system
|
||||
3. Deploy staging environment:
|
||||
- Set up Docker containers for testing
|
||||
- Configure environment variables
|
||||
- Enable SSL certificates
|
||||
- Test on multiple devices and browsers
|
||||
|
||||
## Special Considerations
|
||||
- Maintain WCAG 2.2 AA compliance throughout all components and pages
|
||||
- Focus on creating components that support deaf and DeafBlind users
|
||||
- All videos must have thumbnails, captions, and full transcripts
|
||||
- Ensure keyboard navigation works properly throughout the site
|
||||
- Follow database schema validation using $jsonSchema for data integrity
|
||||
- Apply security best practices in all aspects of the implementation
|
||||
- Ensure strict authentication for all admin routes
|
||||
- Keep all forms accessible and keyboard navigable
|
||||
- Ensure admin interfaces work well on mobile devices
|
||||
- All uploaded content must support WCAG 2.2 AA compliance
|
||||
- Maintain clear separation between public and admin areas
|
||||
- Add proper validation for all form inputs
|
||||
- Implement comprehensive error handling
|
||||
|
|
|
|||
|
|
@ -1,159 +1,167 @@
|
|||
# Product Context: Olathe Club of the Deaf (OCD) Website
|
||||
# Product Context: Olathe Club of the Deaf Website
|
||||
|
||||
## Why This Project Exists
|
||||
## Purpose
|
||||
|
||||
The Olathe Club of the Deaf (OCD) website serves as a digital hub for the deaf community in Olathe, Kansas. The project exists to:
|
||||
The Olathe Club of the Deaf (OCD) website serves as the official digital presence for the Olathe Club of the Deaf, a community organization serving deaf and hard-of-hearing individuals in Olathe, Kansas. The website aims to:
|
||||
|
||||
1. **Improve Accessibility**: Create a fully accessible digital presence that serves deaf, hard-of-hearing, and DeafBlind community members
|
||||
2. **Streamline Operations**: Automate administrative tasks like membership tracking and event management
|
||||
3. **Increase Visibility**: Raise awareness of OCD's activities, events, and services in the broader community
|
||||
4. **Preserve History**: Document and showcase the organization's rich history since 1975
|
||||
5. **Facilitate Communication**: Provide an accessible platform for members to stay informed about club activities
|
||||
6. **Support Governance**: Make organizational bylaws, minutes, and board information readily available
|
||||
1. **Provide Accessible Information**: Deliver critical information about the club's activities, mission, and resources in a fully accessible format.
|
||||
|
||||
The website addresses the lack of a modern, accessible, and functional digital presence for this important community organization.
|
||||
2. **Facilitate Community Engagement**: Enable members and potential members to discover and participate in club events, meetings, and initiatives.
|
||||
|
||||
3. **Streamline Club Administration**: Provide club leadership with tools to manage membership, events, and content without technical expertise.
|
||||
|
||||
4. **Promote Deaf Culture**: Showcase the richness of deaf culture and the club's contributions to the local deaf community.
|
||||
|
||||
5. **Support Organizational Growth**: Attract new members, volunteers, and donors while retaining existing community connections.
|
||||
|
||||
## Problems Solved
|
||||
|
||||
The OCD website addresses several key challenges:
|
||||
|
||||
### For Community Members
|
||||
- **Information Gap**: Previously, community members had difficulty accessing up-to-date information about events, meetings, and organizational news
|
||||
- **Membership Barriers**: The old paper-based membership process was cumbersome and inaccessible
|
||||
- **Resource Access**: Community resources, historical documents, and educational materials were not easily available
|
||||
- **Event Awareness**: Many community members missed events due to inconsistent communication
|
||||
|
||||
### For Organization Leaders
|
||||
- **Administrative Burden**: Manual membership tracking and event management consumed valuable volunteer time
|
||||
- **Communication Challenges**: Reaching all members efficiently was difficult and inconsistent
|
||||
- **Donation Limitations**: No streamlined way to accept donations and support
|
||||
- **Membership Tracking**: Difficulty managing membership statuses, expirations, and renewals
|
||||
- **Content Management**: No easy way to update website content without technical expertise
|
||||
- **Information Access Barriers**: Many deaf individuals face barriers to information that is primarily delivered through audio channels. The website provides visual and text-based information delivery.
|
||||
|
||||
### For Website Visitors
|
||||
- **Accessibility Issues**: Previous digital presence was not fully accessible to deaf, hard-of-hearing, and DeafBlind users
|
||||
- **Content Freshness**: Information was often outdated or incomplete
|
||||
- **Usability Problems**: Navigation and information architecture were confusing
|
||||
- **Mobile Experience**: Previous site was not responsive or mobile-friendly
|
||||
- **Visual Communication**: Lack of ASL videos and visual elements essential for deaf users
|
||||
- **Community Discovery**: Finding local deaf community events and resources can be challenging. The website centralizes information about club activities and resources.
|
||||
|
||||
## How It Should Work
|
||||
- **ASL-First Communication**: Many deaf individuals prefer ASL as their primary language. The website incorporates ASL videos alongside text content.
|
||||
|
||||
### Core User Journeys
|
||||
- **Digital Participation Gap**: The website bridges the gap between online and in-person participation for the deaf community.
|
||||
|
||||
#### Community Member Journey
|
||||
1. Discovers the OCD website through search or referral
|
||||
2. Browses upcoming events and community information
|
||||
3. Learns about membership benefits and application process
|
||||
4. Completes membership application and submits payment
|
||||
5. Receives membership confirmation and welcome materials
|
||||
6. Regularly checks website for events and organizational updates
|
||||
7. Participates in events and community activities
|
||||
8. Renews membership when notified of expiration
|
||||
### For Club Administrators
|
||||
|
||||
#### Organization Administrator Journey
|
||||
1. Logs in to secure admin dashboard
|
||||
2. Manages membership data and tracks renewals
|
||||
3. Creates and updates event information
|
||||
4. Publishes content updates to public pages
|
||||
5. Uploads meeting minutes and organizational documents
|
||||
6. Reviews and responds to contact form submissions
|
||||
7. Generates membership and activity reports
|
||||
8. Manages ASL videos and accessibility features
|
||||
- **Administrative Burden**: Tracking memberships, organizing events, and distributing information manually is time-consuming. The admin system automates these tasks.
|
||||
|
||||
#### Website Visitor Journey
|
||||
1. Visits website to learn about OCD organization
|
||||
2. Explores information about deaf community in Olathe
|
||||
3. Views upcoming events calendar
|
||||
4. Watches accessible ASL videos with captions/transcripts
|
||||
5. Contacts the organization through accessible form
|
||||
6. Makes donations to support the organization
|
||||
7. Shares content with others via social media
|
||||
- **Technological Barriers**: Club leaders may not have technical expertise. The admin interface provides accessible tools for non-technical users.
|
||||
|
||||
### Key Features
|
||||
- **Content Updates**: Maintaining current information was historically difficult. The CMS allows for easy content updates.
|
||||
|
||||
#### Public-Facing Features
|
||||
- **Home Page**: Showcases organization mission, featured events, and announcements
|
||||
- **About Section**: History, mission statement, board members with photos and bios
|
||||
- **Events Calendar**: Interactive calendar with filtering capabilities and event details
|
||||
- **Membership Information**: Benefits, types, pricing, and application process
|
||||
- **Contact Form**: Accessible contact methods with form validation
|
||||
- **Donation Integration**: Zeffy widget for secure online donations
|
||||
- **Document Repository**: Accessible bylaws, minutes, and organizational documents
|
||||
- **ASL Video Library**: Captioned and transcribed ASL videos about the organization
|
||||
- **Resource Distribution**: Sharing meeting minutes, bylaws, and other documents was inefficient. The document repository centralizes these resources.
|
||||
|
||||
#### Administration Features
|
||||
- **Member Management**: Database of members with search, filtering, and export
|
||||
- **Event Management**: Create, edit, and publish events with recurrence options
|
||||
- **Content Management**: WYSIWYG editor for page content updates
|
||||
- **Document Management**: Upload and organize meeting minutes and documents
|
||||
- **Video Management**: Upload ASL videos with caption and transcript support
|
||||
- **Contact Management**: Review and respond to contact form submissions
|
||||
- **Membership Notifications**: Automated email system for membership expirations
|
||||
- **User Management**: Admin account control with secure authentication
|
||||
### For Public Awareness
|
||||
|
||||
### User Types & Needs
|
||||
- **Deaf Awareness**: Many hearing people lack understanding of deaf culture. The website educates the broader community.
|
||||
|
||||
#### Deaf Community Members
|
||||
- ASL videos with captions and transcripts
|
||||
- Visual design that minimizes text-heavy content
|
||||
- Clear, simple navigation
|
||||
- High contrast visual elements
|
||||
- Mobile accessibility for on-the-go access
|
||||
- Event information in visual formats
|
||||
- **Resource Visibility**: Community resources for deaf individuals were not widely known. The website increases visibility of these resources.
|
||||
|
||||
#### DeafBlind Users
|
||||
- Screen reader compatibility
|
||||
- High contrast mode
|
||||
- Simplified content structure
|
||||
- Keyboard navigation support
|
||||
- Text descriptions of all visual elements
|
||||
- Reduced motion option
|
||||
## Target Users
|
||||
|
||||
#### Organization Board Members
|
||||
- Secure access to administrative features
|
||||
- Easy content management without technical skills
|
||||
- Membership data visualization
|
||||
- Export capabilities for reports
|
||||
- Notification management
|
||||
### Primary Users
|
||||
|
||||
#### Potential Members & Community Supporters
|
||||
- Clear information about the organization
|
||||
- Simple donation process
|
||||
- Easy-to-understand membership benefits
|
||||
- Mobile-friendly experience
|
||||
- Social sharing capabilities
|
||||
1. **Current OCD Members**
|
||||
- Age range: 18-80+
|
||||
- Technical proficiency: Varies widely
|
||||
- Need: Access event information, membership details, club updates
|
||||
|
||||
## Special Considerations
|
||||
2. **Club Leadership & Board Members**
|
||||
- Role: Volunteer administrators
|
||||
- Technical proficiency: Basic to intermediate
|
||||
- Need: Manage content, track memberships, coordinate events
|
||||
|
||||
### Accessibility Requirements
|
||||
- WCAG 2.2 AA compliance as minimum standard
|
||||
- ASL videos must have captions and transcripts
|
||||
- All functionality must be keyboard accessible
|
||||
- Screen reader testing and optimization
|
||||
- Color contrast ratios ≥ 4.5:1 for normal text
|
||||
- Support for text resizing up to 200% without loss of content
|
||||
- Reduced motion preferences respected
|
||||
- Focus indicators clear and visible
|
||||
3. **Prospective Members**
|
||||
- Deaf or hard-of-hearing individuals in Olathe area
|
||||
- May be new to the area or community
|
||||
- Need: Learn about the club, membership benefits, upcoming events
|
||||
|
||||
### Content Management
|
||||
- Non-technical administrators must be able to update content
|
||||
- ASL video uploads must be simple with caption/transcript support
|
||||
- Automated backups of all content
|
||||
- Version history for content changes
|
||||
- Role-based permissions for different admin functions
|
||||
### Secondary Users
|
||||
|
||||
### Technical Requirements
|
||||
- Fast loading times (especially on mobile)
|
||||
- Secure authentication for admin features
|
||||
- HTTPS encryption for all traffic
|
||||
- Regular security updates
|
||||
- Daily database backups
|
||||
- Responsive design for all device sizes
|
||||
- Cross-browser compatibility
|
||||
- Email notification system for expirations
|
||||
4. **Deaf Community Supporters**
|
||||
- Family members, interpreters, educators
|
||||
- Connection: Support deaf individuals or the community
|
||||
- Need: Event information, volunteer opportunities, donation options
|
||||
|
||||
### Legal & Privacy
|
||||
- Clear privacy policy
|
||||
- Secure handling of member data
|
||||
- GDPR-compliant data practices
|
||||
- Terms of service for website use
|
||||
- Accessibility statement with contact information
|
||||
5. **Community Partners**
|
||||
- Local businesses, schools, government agencies
|
||||
- Relationship: Collaborate with OCD on initiatives
|
||||
- Need: Contact information, organizational details
|
||||
|
||||
6. **Researchers & Media**
|
||||
- Studying deaf community or seeking information
|
||||
- Need: Historical information, contact details, mission information
|
||||
|
||||
## User Journeys
|
||||
|
||||
### Member Journey: Event Participation
|
||||
|
||||
1. **Discovery**: Member visits website to learn about upcoming events
|
||||
2. **Information Gathering**: Views event details including date, time, location
|
||||
3. **Decision**: Decides to attend based on accessible information
|
||||
4. **Preparation**: Uses location map to plan transportation
|
||||
5. **Follow-up**: Returns to website after event to view photos or minutes
|
||||
|
||||
### Administrator Journey: Event Management
|
||||
|
||||
1. **Creation**: Logs into admin dashboard to create new event
|
||||
2. **Setup**: Enters event details, uploads images, sets category
|
||||
3. **Publication**: Publishes event to website and highlights on homepage
|
||||
4. **Management**: Monitors registrations if applicable
|
||||
5. **Post-Event**: Uploads photos or minutes after event concludes
|
||||
|
||||
### New Member Journey: Joining the Club
|
||||
|
||||
1. **Introduction**: Discovers website through search or referral
|
||||
2. **Exploration**: Browses about section, history, and mission
|
||||
3. **Consideration**: Reviews membership benefits and requirements
|
||||
4. **Application**: Completes online membership application form
|
||||
5. **Integration**: Receives welcome email with next steps
|
||||
6. **Engagement**: Begins participating in club events
|
||||
|
||||
### Public Journey: Learning About Deaf Culture
|
||||
|
||||
1. **Discovery**: Visits website seeking information about deaf community
|
||||
2. **Education**: Explores pages about deaf culture and ASL
|
||||
3. **Awareness**: Views ASL videos with accompanying transcripts
|
||||
4. **Connection**: Uses contact form to request more information
|
||||
5. **Engagement**: Signs up for newsletter or follows on social media
|
||||
|
||||
## Key Features & Priorities
|
||||
|
||||
### Critical Features (Must-Have)
|
||||
|
||||
- **Accessible Design**: WCAG 2.2 AA compliance with deaf-specific considerations
|
||||
- **Event Calendar**: Browsable calendar with detailed event information
|
||||
- **Membership Management**: Member tracking and status management
|
||||
- **Content Management**: Easy updates for non-technical administrators
|
||||
- **Document Repository**: Storage for minutes, bylaws, and forms
|
||||
|
||||
### Important Features (Should-Have)
|
||||
|
||||
- **ASL Video Support**: Video content with transcripts and subtitles
|
||||
- **Contact Functionality**: Forms for inquiries and feedback
|
||||
- **Responsive Design**: Mobile-friendly experience across devices
|
||||
- **Board Member Information**: Leadership profiles and roles
|
||||
- **Email Integration**: Notification system for members
|
||||
|
||||
### Enhancement Features (Nice-to-Have)
|
||||
|
||||
- **Member Portal**: Personal dashboard for individual members
|
||||
- **Donation Integration**: Online donation capabilities
|
||||
- **Social Media Integration**: Sharing and cross-posting capabilities
|
||||
- **Event Registration**: RSVP functionality for events
|
||||
- **Photo Gallery**: Event photo albums
|
||||
|
||||
## Accessibility Requirements
|
||||
|
||||
As an organization serving the deaf community, accessibility is paramount. The website must include:
|
||||
|
||||
- **Visual Communication Priority**: Information presented visually first
|
||||
- **ASL Video Content**: Key information presented in ASL
|
||||
- **Text Alternatives**: Captions and transcripts for all video content
|
||||
- **High Contrast Design**: Clear visual distinction for all users
|
||||
- **Screen Reader Support**: For DeafBlind community members
|
||||
- **Keyboard Navigation**: Full functionality without mouse use
|
||||
- **Simple Language**: Clear, concise content for varied reading levels
|
||||
- **Reduced Motion Options**: For users with vestibular disorders
|
||||
- **Responsive Text Sizing**: Adjustable text without breaking layouts
|
||||
|
||||
## Success Metrics
|
||||
|
||||
The OCD website's success will be measured by:
|
||||
|
||||
1. **Member Engagement**: Increase in event attendance and participation
|
||||
2. **Administrative Efficiency**: Reduction in manual administrative tasks
|
||||
3. **Membership Growth**: New member applications through the website
|
||||
4. **Content Freshness**: Frequency of content updates
|
||||
5. **Accessibility Compliance**: WCAG 2.2 AA audit score
|
||||
6. **User Satisfaction**: Feedback from community members
|
||||
7. **Resource Utilization**: Downloads of documents and forms
|
||||
|
|
|
|||
|
|
@ -1,68 +1,129 @@
|
|||
# Project Progress: OCD Website
|
||||
|
||||
## What Works
|
||||
- Complete project structure created according to the system patterns
|
||||
- Essential configuration files for development environment setup:
|
||||
- Docker Compose with multi-stage builds and security
|
||||
- Frontend and backend Dockerfiles with security best practices
|
||||
- Package.json files with dependencies
|
||||
- Next.js configuration with security headers
|
||||
- Tailwind CSS configuration with CSS-first approach
|
||||
- Nginx configuration with security headers
|
||||
- Core implementations:
|
||||
- Navigation component with responsive design and accessibility features
|
||||
- Footer component with site map and accessibility options
|
||||
- Hero component with animation and adaptability
|
||||
- Contact form with validation and ARIA attributes
|
||||
- Public-facing pages including Home, About, and Contact
|
||||
- Custom Video Player component with accessibility features and transcript display
|
||||
- MongoDB initialization scripts with schema validation
|
||||
- Database models with validation for Videos and Members
|
||||
- Authentication middleware with JWT and RBAC
|
||||
- Directory structure for both frontend and backend
|
||||
## Completed Features
|
||||
|
||||
## What's Left to Build
|
||||
The implementation has a solid foundation, but these key features still need to be built:
|
||||
### Phase 1: Project Setup & Infrastructure
|
||||
- [x] Initialize Next.js project with TypeScript
|
||||
- [x] Configure project structure (app directory, API routes)
|
||||
- [x] Set up ESLint with accessibility rules
|
||||
- [x] Create basic layout components (Header, Footer, Navigation)
|
||||
- [x] Implement responsive design breakpoints
|
||||
- [x] Configure MongoDB connection
|
||||
- [x] Set up Redis for caching
|
||||
- [x] Implement Docker development environment
|
||||
- [x] Create authentication middleware
|
||||
- [x] Configure admin login system
|
||||
- [x] Set up email service connection
|
||||
|
||||
1. Events and Calendar pages and functionality
|
||||
2. Membership information and application pages
|
||||
3. Admin dashboard and authentication flow
|
||||
4. Full event management system
|
||||
5. Membership tracking and notification system
|
||||
6. Document repository with meeting minutes and bylaws
|
||||
7. Full implementation of video management with upload functionality
|
||||
8. Integration with Zeffy for donations
|
||||
9. Email notification system implementation
|
||||
10. Final deployment and testing
|
||||
### Phase 2: Core Public Pages
|
||||
- [x] Implement home page with featured events section
|
||||
- [x] Create About OCD section pages
|
||||
- [x] History page
|
||||
- [x] Mission statement page
|
||||
- [x] Board members page with photo grid
|
||||
- [x] Implement bylaws page with document viewer
|
||||
- [x] Create minutes archive with filtering and search
|
||||
- [x] Develop responsive navigation system
|
||||
- [x] Implement SEO optimization
|
||||
- [x] Dynamic meta tags
|
||||
- [x] Structured data for events
|
||||
- [x] Sitemap generation
|
||||
- [x] Create contact form with validation and email notifications
|
||||
- [x] Implement breadcrumb navigation
|
||||
|
||||
For a detailed breakdown of specific tasks, refer to TO_DO.txt.
|
||||
### Phase 3: Admin Dashboard
|
||||
- [x] Create dashboard layout and navigation
|
||||
- [x] Implement admin authentication and authorization
|
||||
- [x] Develop dashboard overview with statistics
|
||||
- [x] Create event management interface
|
||||
- [x] Event listing with filtering
|
||||
- [x] Event creation/editing form
|
||||
- [x] Category management
|
||||
- [x] Build member management interface
|
||||
- [x] Member directory with filtering
|
||||
- [x] Member profile editing
|
||||
- [x] Membership status tracking
|
||||
- [x] Implement document repository
|
||||
- [x] Document upload and categorization
|
||||
- [x] Permission controls (public/private)
|
||||
- [x] Document search and filtering
|
||||
- [x] Create content management system
|
||||
- [x] Page listing and organization
|
||||
- [x] WYSIWYG editor interface
|
||||
- [x] Publish/unpublish functionality
|
||||
- [x] Implement settings interface
|
||||
- [x] General site settings
|
||||
- [x] Email configuration
|
||||
- [x] Membership settings
|
||||
- [x] Accessibility options
|
||||
- [x] Develop video management system
|
||||
- [x] Video listing and organization
|
||||
- [x] Accessibility indicators for subtitles
|
||||
- [x] Publishing controls
|
||||
|
||||
## Progress Status
|
||||
- **Current Phase**: Phase 1 - Core setup and public-facing pages
|
||||
- **Tasks Completed**:
|
||||
- Updated technology stack to latest versions (Next.js 15.2.3, TypeScript 5.8, Tailwind CSS 4.0)
|
||||
- Created complete directory structure according to system patterns
|
||||
- Implemented core configuration files (Docker, Nginx, Tailwind, Next.js)
|
||||
- Created MongoDB initialization scripts with schema validation
|
||||
- Implemented Video Player component with transcript display
|
||||
- Set up authentication middleware with JWT
|
||||
- Created model files with validation for Videos and Members
|
||||
- Created basic API routes structure
|
||||
- Enhanced database schemas with validation and indexing
|
||||
- Upgraded accessibility to WCAG 2.2 AA compliance requirements
|
||||
- Added required video transcript and thumbnail specifications
|
||||
- Implemented Docker setup with security best practices
|
||||
- Created responsive Navigation and Footer components
|
||||
- Implemented Home, About, and Contact pages with accessibility features
|
||||
- Created Contact form with validation and ARIA attributes
|
||||
- **Tasks In Progress**:
|
||||
- Events and Calendar functionality
|
||||
- Membership section development
|
||||
- **Next Tasks**:
|
||||
- Complete remaining public-facing pages (Events, Membership)
|
||||
- Begin admin dashboard implementation
|
||||
- Implement event management system
|
||||
- Set up video upload and management functionality
|
||||
## In Progress Features
|
||||
|
||||
## Current Task
|
||||
Implementing additional public-facing pages to complete Phase 2, with focus on the event calendar and membership information sections. Once those are complete, we'll move on to the admin functionality in Phase 3.
|
||||
### Phase 4: API Integration & Backend Functionality
|
||||
- [ ] Connect admin interfaces to backend API endpoints
|
||||
- [ ] Implement real data loading with state management
|
||||
- [ ] Create API error handling and recovery
|
||||
- [ ] Develop form submissions with validation
|
||||
- [ ] Build media upload functionality
|
||||
- [ ] Implement user notification system
|
||||
- [ ] Create advanced filtering for data tables
|
||||
- [ ] Develop data export functionality
|
||||
- [ ] Build search functionality across system
|
||||
|
||||
### Phase 5: Accessibility & Refinement
|
||||
- [ ] Conduct comprehensive accessibility audit
|
||||
- [ ] Implement screen reader optimizations
|
||||
- [ ] Refine keyboard navigation
|
||||
- [ ] Add focus management system
|
||||
- [ ] Implement reduced motion alternatives
|
||||
- [ ] Create high contrast mode
|
||||
- [ ] Optimize for text resizing
|
||||
- [ ] Test with assistive technologies
|
||||
- [ ] Add accessibility documentation
|
||||
|
||||
## Upcoming Features
|
||||
|
||||
### Phase 6: Testing & Optimization
|
||||
- [ ] Conduct user testing with club members
|
||||
- [ ] Perform performance optimization
|
||||
- [ ] Image optimization
|
||||
- [ ] Code splitting
|
||||
- [ ] Bundle size reduction
|
||||
- [ ] Implement caching strategies
|
||||
- [ ] Conduct security audit
|
||||
- [ ] Fix identified bugs
|
||||
- [ ] Refine UI/UX based on feedback
|
||||
- [ ] Optimize database queries
|
||||
- [ ] Create automated testing suite
|
||||
|
||||
### Phase 7: Deployment & Documentation
|
||||
- [ ] Set up production Docker environment
|
||||
- [ ] Configure Nginx and SSL
|
||||
- [ ] Implement automated backups
|
||||
- [ ] Create deployment documentation
|
||||
- [ ] Develop admin user guide
|
||||
- [ ] Create technical documentation
|
||||
- [ ] Implement monitoring and alerting
|
||||
- [ ] Conduct training session for administrators
|
||||
|
||||
## Technical Debt & Improvements
|
||||
- [ ] Refine type definitions for stronger typing
|
||||
- [ ] Improve error boundary implementation
|
||||
- [ ] Enhance logging and monitoring
|
||||
- [ ] Create more comprehensive unit tests
|
||||
- [ ] Optimize database indexing
|
||||
- [ ] Refine component reusability
|
||||
- [ ] Consolidate duplicate styling
|
||||
- [ ] Add comprehensive JSDoc comments
|
||||
|
||||
## Project Stats
|
||||
- **Completed Tasks:** 36
|
||||
- **In Progress Tasks:** 9
|
||||
- **Upcoming Tasks:** 23
|
||||
- **Completion Rate:** ~53%
|
||||
- **Current Phase:** Transitioning from Phase 3 to Phase 4
|
||||
- **Next Major Milestone:** Full API integration
|
||||
|
|
|
|||
|
|
@ -2,164 +2,221 @@
|
|||
|
||||
## Architecture Patterns
|
||||
|
||||
### Frontend Architecture
|
||||
- **Component Structure**: Atomic Design methodology
|
||||
- **Atoms**: Buttons, inputs, icons, typography elements
|
||||
- **Molecules**: Forms, cards, navigation items, video player
|
||||
- **Organisms**: Navigation bar, page sections, footer
|
||||
- **Templates**: Page layouts with content placeholders
|
||||
- **Pages**: Complete views with actual content
|
||||
- **Data Flow**: Unidirectional data flow (React standard)
|
||||
- **State Management**: React Context API for global state
|
||||
- **Rendering Strategy**:
|
||||
- Server-side rendering for initial page load and SEO
|
||||
- Client-side rendering for interactive components
|
||||
- Static generation for stable content
|
||||
- **Styling Approach**:
|
||||
- Utility-first with Tailwind CSS
|
||||
- Component-specific styles via custom CSS
|
||||
- CSS variables for theming and consistency
|
||||
- **Responsive Design**:
|
||||
- Mobile-first breakpoints (sm, md, lg, xl)
|
||||
- Container-based fluid layouts
|
||||
- Flexbox and CSS Grid for complex layouts
|
||||
### Component Architecture
|
||||
- **Atomic Design Methodology**
|
||||
- **Atoms**: Basic building blocks (buttons, inputs, icons)
|
||||
- **Molecules**: Simple groups of UI elements (form fields, cards)
|
||||
- **Organisms**: Complex UI components (navigation, forms)
|
||||
- **Templates**: Page layouts without specific content
|
||||
- **Pages**: Templates with real content
|
||||
|
||||
### Backend Architecture
|
||||
- **API Design**: RESTful with resource-based endpoints
|
||||
- **Controller Pattern**: Route handlers separated from business logic
|
||||
- **Repository Pattern**: Data access abstracted from controllers
|
||||
- **Middleware Stack**:
|
||||
- Authentication/Authorization
|
||||
- Request validation
|
||||
- Error handling
|
||||
- Logging
|
||||
- CORS handling
|
||||
- **Data Models**: MongoDB schemas with validation
|
||||
- **Service Layer**: Business logic encapsulated in service modules
|
||||
- **File Organization**
|
||||
- Components grouped by type (atoms, molecules, organisms)
|
||||
- Pages in Next.js app directory structure
|
||||
- Shared utilities in separate folders
|
||||
- Clear separation between frontend and backend
|
||||
|
||||
## Key Technical Decisions
|
||||
### State Management
|
||||
- **React Context API** for global state
|
||||
- Authentication context for user sessions
|
||||
- UI context for theme and preferences
|
||||
- Toast notification context for system messages
|
||||
- **Local component state** for UI-specific state
|
||||
- **Form state** handled with controlled components
|
||||
- **API state** managed with custom hooks
|
||||
|
||||
### Next.js App Router
|
||||
- File-based routing with nested layouts
|
||||
- Server components for performance
|
||||
- Client components marked with 'use client' for interactivity
|
||||
- Metadata API for SEO optimization
|
||||
### Data Flow Patterns
|
||||
- **Unidirectional data flow**
|
||||
- Props down, events up
|
||||
- Context providers at appropriate levels
|
||||
- **Container/Presenter pattern**
|
||||
- Container components handle data fetching and state
|
||||
- Presenter components are pure rendering functions
|
||||
- **Custom hooks** for reusable logic
|
||||
- useAuth for authentication
|
||||
- useFetch for data fetching
|
||||
- useForm for form handling
|
||||
|
||||
### Tailwind CSS 4.0
|
||||
- JIT (Just-In-Time) compilation for optimal CSS bundle size
|
||||
- Extended theme for custom design tokens
|
||||
- CSS variables for runtime theming
|
||||
- Utility-first approach for rapid development
|
||||
## Frontend Patterns
|
||||
|
||||
### Accessibility First
|
||||
- WCAG 2.2 AA compliance as baseline requirement
|
||||
- Semantic HTML structure
|
||||
- ARIA attributes used appropriately
|
||||
- Keyboard navigation focus management
|
||||
- Screen reader announcements for dynamic content
|
||||
- High contrast mode support
|
||||
- Reduced motion option
|
||||
### Styling Approach
|
||||
- **Utility-first CSS with Tailwind**
|
||||
- Consistent design tokens through Tailwind config
|
||||
- Custom utilities for project-specific needs
|
||||
- Component classes for common patterns
|
||||
- **CSS variables for theming**
|
||||
- Core colors defined as CSS variables
|
||||
- Dark mode implementation using CSS variables
|
||||
- Responsive design using Tailwind breakpoints
|
||||
- **Component-specific styles when needed**
|
||||
- Modules used for complex styling needs
|
||||
- Animation styles with Framer Motion
|
||||
|
||||
### Docker Containerization
|
||||
- Multi-stage builds for production optimization
|
||||
- Development environment consistency
|
||||
- Separate containers for frontend, backend, and database
|
||||
- Volume mounting for source code in development
|
||||
- Environment variable management
|
||||
### Responsive Design
|
||||
- **Mobile-first approach**
|
||||
- Default styles for mobile
|
||||
- Progressive enhancement for larger screens
|
||||
- **Breakpoint system**
|
||||
- sm: 640px and up
|
||||
- md: 768px and up
|
||||
- lg: 1024px and up
|
||||
- xl: 1280px and up
|
||||
- 2xl: 1536px and up
|
||||
- **Flexible layouts**
|
||||
- CSS Grid for page layouts
|
||||
- Flexbox for component layouts
|
||||
|
||||
### JWT Authentication
|
||||
- HTTP-only cookies for security
|
||||
- Role-based access control
|
||||
- Token refresh strategy
|
||||
- Secure routes with middleware
|
||||
### Form Handling
|
||||
- **Controlled components** for form inputs
|
||||
- **Field validation** with client-side validation
|
||||
- **Error handling** with inline error messages
|
||||
- **Form submission** with loading states
|
||||
- **Accessibility considerations**
|
||||
- Proper labeling
|
||||
- Error association
|
||||
- Keyboard navigation
|
||||
|
||||
## Code Organization
|
||||
### Routing
|
||||
- **Next.js App Router**
|
||||
- File-based routing for pages
|
||||
- Dynamic routes for entity details
|
||||
- Route groups for organization
|
||||
- Layouts for shared UI elements
|
||||
- **Protected routes** for admin sections
|
||||
- Auth middleware
|
||||
- Redirect to login if unauthorized
|
||||
|
||||
### Frontend Structure
|
||||
```
|
||||
frontend/
|
||||
├── public/ # Static assets
|
||||
├── src/
|
||||
│ ├── app/ # Next.js routes and pages
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── atoms/ # Basic UI elements
|
||||
│ │ ├── molecules/ # Compound components
|
||||
│ │ ├── organisms/ # Complex components
|
||||
│ │ └── video/ # Video-related components
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── lib/ # Utility functions and helpers
|
||||
│ ├── styles/ # Global styles and Tailwind config
|
||||
│ └── types/ # TypeScript type definitions
|
||||
└── tests/ # Test suites
|
||||
```
|
||||
## Backend Patterns
|
||||
|
||||
### Backend Structure
|
||||
```
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── config/ # Configuration settings
|
||||
│ ├── controllers/ # Request handlers
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ ├── models/ # Data models and schemas
|
||||
│ ├── routes/ # API route definitions
|
||||
│ ├── services/ # Business logic
|
||||
│ └── utils/ # Helper functions
|
||||
└── tests/ # Test suites
|
||||
```
|
||||
### API Design
|
||||
- **RESTful endpoints**
|
||||
- Resource-based URLs
|
||||
- Appropriate HTTP methods
|
||||
- Consistent response format
|
||||
- Status codes for error handling
|
||||
- **Controller pattern**
|
||||
- Route handlers for request parsing
|
||||
- Controllers for business logic
|
||||
- Services for reusable operations
|
||||
- Models for data access
|
||||
|
||||
## Design Patterns
|
||||
|
||||
### Component Composition
|
||||
- Prefer composition over inheritance
|
||||
- Small, focused components with single responsibilities
|
||||
- Higher-order components for shared functionality
|
||||
- Render props for flexible component APIs
|
||||
|
||||
### Custom Hooks
|
||||
- Extract reusable stateful logic into custom hooks
|
||||
- Naming convention: use[HookName]
|
||||
- Keep hooks focused on specific concerns
|
||||
### Authentication
|
||||
- **JWT-based authentication**
|
||||
- JWTs stored in HTTP-only cookies
|
||||
- Short expiration with refresh tokens
|
||||
- CSRF protection with tokens
|
||||
- **Permission-based authorization**
|
||||
- Role-based access control
|
||||
- Resource ownership checks
|
||||
- Fine-grained permissions
|
||||
|
||||
### Error Handling
|
||||
- Try/catch blocks in async functions
|
||||
- Error boundaries for UI component failures
|
||||
- Consistent error response format in API
|
||||
- Detailed logging for debugging
|
||||
- **Centralized error handling middleware**
|
||||
- Consistent error format
|
||||
- Appropriate status codes
|
||||
- Detailed error messages in development
|
||||
- Sanitized messages in production
|
||||
- **Try/catch blocks** for async operations
|
||||
- **Validation errors** for input validation
|
||||
|
||||
### Dependency Injection
|
||||
- Service dependencies provided via parameters
|
||||
- Loose coupling between modules
|
||||
- Facilitates testing with mocks
|
||||
## Database Patterns
|
||||
|
||||
## Naming Conventions
|
||||
### Schema Design
|
||||
- **Document-based design** for MongoDB
|
||||
- Embedded documents for related data
|
||||
- References for shared entities
|
||||
- Denormalization for performance
|
||||
- **Validation schemas** with Mongoose
|
||||
- Required fields
|
||||
- Field types and constraints
|
||||
- Custom validators
|
||||
- **Indexes** for frequently queried fields
|
||||
|
||||
### Frontend
|
||||
- **Component Files**: PascalCase.tsx
|
||||
- **Hook Files**: camelCase.ts
|
||||
- **Utility Files**: camelCase.ts
|
||||
- **CSS Modules**: camelCase.module.css
|
||||
- **Component Props**: interface ComponentNameProps
|
||||
### Query Patterns
|
||||
- **Repository pattern** for data access
|
||||
- Encapsulated database operations
|
||||
- Reusable query methods
|
||||
- Consistent error handling
|
||||
- **Pagination** for large collections
|
||||
- Limit/skip approach
|
||||
- Cursor-based pagination for efficiency
|
||||
- **Filtering** with query parameters
|
||||
- Dynamic filter building
|
||||
- Validation of filter parameters
|
||||
|
||||
### Backend
|
||||
- **Controllers**: camelCase.controller.ts
|
||||
- **Models**: PascalCase.model.ts
|
||||
- **Routes**: camelCase.routes.ts
|
||||
- **Services**: camelCase.service.ts
|
||||
- **Middleware**: camelCase.middleware.ts
|
||||
## Testing Patterns
|
||||
|
||||
## Testing Strategy
|
||||
- **Frontend**:
|
||||
- Component tests with React Testing Library
|
||||
- E2E tests with Playwright
|
||||
- Visual regression tests
|
||||
- **Backend**:
|
||||
- Unit tests for business logic
|
||||
- Integration tests for API endpoints
|
||||
- Mock database for testing
|
||||
### Unit Testing
|
||||
- **Component testing** with React Testing Library
|
||||
- Component rendering
|
||||
- User interactions
|
||||
- State changes
|
||||
- **Hook testing** with custom test utilities
|
||||
- **Service testing** for business logic
|
||||
|
||||
## CI/CD Pipeline (Planned)
|
||||
- GitHub Actions for automated builds
|
||||
- Linting and type checking
|
||||
- Automated testing
|
||||
- Docker image building
|
||||
- Deployment to production server
|
||||
### Integration Testing
|
||||
- **API endpoint testing**
|
||||
- Request validation
|
||||
- Response structure
|
||||
- Error handling
|
||||
- **Database interaction testing**
|
||||
- Create, read, update, delete operations
|
||||
- Schema validation
|
||||
- Indexing performance
|
||||
|
||||
### End-to-End Testing
|
||||
- **User flow testing**
|
||||
- Critical user journeys
|
||||
- Form submissions
|
||||
- Authentication flows
|
||||
- **Accessibility testing**
|
||||
- Automated a11y checks
|
||||
- Manual screen reader testing
|
||||
- Keyboard navigation testing
|
||||
|
||||
## Deployment Patterns
|
||||
|
||||
### Docker Containerization
|
||||
- **Multi-stage builds** for production
|
||||
- Build stage for dependencies and compilation
|
||||
- Production stage for runtime
|
||||
- Minimal production image
|
||||
- **Docker Compose** for local development
|
||||
- Service definitions
|
||||
- Volume mappings
|
||||
- Network configuration
|
||||
|
||||
### Environment Configuration
|
||||
- **Environment-specific variables**
|
||||
- Development settings
|
||||
- Production settings
|
||||
- Testing settings
|
||||
- **Secret management**
|
||||
- Environment variables for sensitive data
|
||||
- Docker secrets for production
|
||||
|
||||
## Accessibility Patterns
|
||||
|
||||
### WCAG 2.2 Compliance
|
||||
- **Semantic HTML**
|
||||
- Proper heading hierarchy
|
||||
- Meaningful element choices
|
||||
- Landmark regions
|
||||
- **ARIA attributes** when needed
|
||||
- Role definitions
|
||||
- State indications
|
||||
- Relationship attributes
|
||||
- **Keyboard navigation**
|
||||
- Focus management
|
||||
- Logical tab order
|
||||
- Skip links
|
||||
- **Screen reader support**
|
||||
- Alt text for images
|
||||
- Descriptive link text
|
||||
- Status announcements
|
||||
- **Color contrast**
|
||||
- Meeting WCAG AA contrast ratios
|
||||
- Not relying on color alone
|
||||
- **Motion sensitivity**
|
||||
- Respecting reduced motion preferences
|
||||
- Careful use of animations
|
||||
|
|
|
|||
|
|
@ -1,98 +1,175 @@
|
|||
# Technical Context: OCD Website
|
||||
|
||||
## Technologies Used
|
||||
## Technology Stack
|
||||
|
||||
### Frontend
|
||||
- **Framework**: Next.js 15.2.3 - Server-side rendering with React
|
||||
- **Language**: TypeScript 5.8 - Type-safe development
|
||||
- **UI Components**: React 19.0.0 - Component-based architecture
|
||||
- **Framework**: Next.js 15.2.3 with React 19
|
||||
- **Language**: TypeScript 5.8
|
||||
- **Styling**:
|
||||
- Tailwind CSS 4.0.15 - Utility-first CSS framework
|
||||
- CSS Variables - Custom theming system
|
||||
- PostCSS 8.4 - CSS processing
|
||||
- Autoprefixer 10.4 - CSS vendor prefixing
|
||||
- **Animation**: Framer Motion 12.6.0 - Interactive animations
|
||||
- **Routing**: Next.js App Router - File-based routing with layouts
|
||||
- **Icons**: Custom SVG icons - Accessibility-focused
|
||||
- **Forms**: Custom form components with validation
|
||||
- Tailwind CSS 4.0
|
||||
- PostCSS 8.4 with Autoprefixer 10.4
|
||||
- CSS variables for theming
|
||||
- **State Management**: React Context API
|
||||
- **Routing**: Next.js App Router
|
||||
- **Animations**: Framer Motion 11
|
||||
- **Icons**: React Icons 5.5.0
|
||||
- **Maps**: React Simple Maps 3.0.0
|
||||
- **Utility Libraries**:
|
||||
- React Intersection Observer 9.16.0
|
||||
- Axios 1.6 for API requests
|
||||
- date-fns for date manipulation
|
||||
|
||||
### Backend
|
||||
- **Framework**: Node.js with Express 4.21
|
||||
- **Language**: TypeScript - Type-safe API development
|
||||
- **Database**: MongoDB - Document-based storage
|
||||
- **API**: RESTful endpoints
|
||||
- **Email**: Nodemailer 6.9 - Email notifications
|
||||
- **Framework**: Node.js with Express 4.18.2
|
||||
- **Language**: TypeScript 5.8
|
||||
- **API Design**: RESTful endpoints
|
||||
- **Email Handling**: Nodemailer 6.9
|
||||
- **Authentication**: JWT-based with HTTP-only cookies
|
||||
- **Data Validation**: Server-side schema validation
|
||||
- **Cross-Origin**: CORS middleware
|
||||
- **Environment Variables**: dotenv 16.4
|
||||
- **Caching & Rate Limiting**: Upstash Redis 1.28
|
||||
|
||||
### Development Tools
|
||||
### Database
|
||||
- **Primary Database**: MongoDB with Mongoose ODM
|
||||
- **Caching Layer**: Redis
|
||||
- **Schema Validation**: Mongoose schemas with validation
|
||||
- **Data Models**:
|
||||
- Admin User
|
||||
- Member
|
||||
- Event
|
||||
- Page Content
|
||||
- Document
|
||||
- Video
|
||||
- Contact Submission
|
||||
|
||||
### Development & Build Tools
|
||||
- **Package Manager**: pnpm 9.14
|
||||
- **Containerization**: Docker & Docker Compose
|
||||
- **Build Tool**: Vite 6.0
|
||||
- **Linting**: ESLint 9
|
||||
- **Build System**: Next.js built-in bundler
|
||||
- **Version Control**: Git
|
||||
- **Container**: Docker & Docker Compose
|
||||
- **Configuration**: Environment variables with dotenv
|
||||
- **Caching**: Redis (planned)
|
||||
- **Process Management**: Concurrently for multiple processes
|
||||
- **TypeScript Execution**: tsx/ts-node
|
||||
- **Hot Module Replacement**: Enabled
|
||||
|
||||
### Deployment
|
||||
- **Production Server**: Nginx reverse proxy
|
||||
- **Containers**: Docker multi-stage builds
|
||||
- **Database**: MongoDB Atlas (planned)
|
||||
- **Cache**: Redis (planned)
|
||||
- **CI/CD**: GitHub Actions (planned)
|
||||
### Production Environment
|
||||
- **Web Server**: Nginx for static assets and reverse proxy
|
||||
- **API Server**: Node.js
|
||||
- **Containerization**: Docker with multi-stage builds
|
||||
- **SSL**: Let's Encrypt certificates
|
||||
- **Monitoring**: Basic health checks
|
||||
- **Logging**: Winston logger with structured format
|
||||
|
||||
## Development Environment
|
||||
## Architecture Overview
|
||||
|
||||
### Local Setup
|
||||
- Next.js development server (localhost:3000)
|
||||
- Express API server (localhost:4000)
|
||||
- MongoDB container
|
||||
- Tailwind CSS JIT compilation
|
||||
- Hot module reloading
|
||||
### Frontend Architecture
|
||||
- **Server Components**: Used for static pages (About, Bylaws)
|
||||
- **Client Components**: Used for interactive elements
|
||||
- **Rendering Strategy**:
|
||||
- SSR for critical pages (Home, Events)
|
||||
- Static Generation for stable content
|
||||
- Client-side rendering for interactive components
|
||||
- **Component Structure**:
|
||||
- Atomic design principles (atoms, molecules, organisms)
|
||||
- Layout components for consistent page structure
|
||||
- Page-specific components
|
||||
- **Data Flow**:
|
||||
- React Context for application state
|
||||
- API hooks for data fetching
|
||||
- Form state handled locally
|
||||
|
||||
### Key Configuration Files
|
||||
- **docker-compose.yml**: Container orchestration
|
||||
- **frontend/next.config.js**: Next.js configuration
|
||||
- **frontend/tailwind.config.js**: Tailwind customization
|
||||
- **frontend/postcss.config.js**: CSS processing
|
||||
- **backend/.env**: API environment variables
|
||||
- **nginx/conf/default.conf**: Production web server
|
||||
### Backend Architecture
|
||||
- **API Layer**: RESTful endpoints organized by resource
|
||||
- **Authentication Layer**: JWT middleware for protected routes
|
||||
- **Controller Layer**: Handles business logic
|
||||
- **Model Layer**: Mongoose models with validation
|
||||
- **Email Service**: Nodemailer for transactional emails
|
||||
- **File Handling**: Multer for uploads
|
||||
|
||||
## Technical Constraints
|
||||
### Database Design
|
||||
- Document-oriented schema with Mongoose
|
||||
- Relationships handled through references
|
||||
- Indexing for frequently queried fields
|
||||
- Validation at the schema level
|
||||
|
||||
### Accessibility Requirements
|
||||
- WCAG 2.2 AA compliance
|
||||
- Screen reader compatibility
|
||||
## Development Setup
|
||||
|
||||
### Local Development
|
||||
- Docker Compose for local environment
|
||||
- Frontend on port 3000
|
||||
- Backend API on port 4000
|
||||
- MongoDB on port 27017
|
||||
- Redis on port 6379
|
||||
- Hot reloading enabled for development
|
||||
|
||||
### Environment Configuration
|
||||
- Frontend environment variables in .env.local
|
||||
- Backend environment variables in .env
|
||||
- Production variables set through Docker
|
||||
|
||||
### CI/CD Flow
|
||||
- Manual deployment to production
|
||||
- Build process in Docker
|
||||
- Multi-stage builds for smaller images
|
||||
|
||||
## API Structure
|
||||
|
||||
### Authentication Endpoints
|
||||
- POST /api/auth/login
|
||||
- POST /api/auth/logout
|
||||
- POST /api/auth/reset-password
|
||||
- POST /api/auth/set-password
|
||||
|
||||
### Content Management Endpoints
|
||||
- GET/POST/PUT/DELETE /api/pages
|
||||
- GET/PUT /api/pages/:slug
|
||||
- PUT /api/pages/:id/publish
|
||||
|
||||
### Event Endpoints
|
||||
- GET/POST/PUT/DELETE /api/events
|
||||
- GET /api/events/:id
|
||||
- GET /api/events/calendar/:year/:month
|
||||
|
||||
### Member Endpoints
|
||||
- GET/POST/PUT/DELETE /api/members
|
||||
- GET /api/members/expiring
|
||||
- POST /api/members/notify
|
||||
|
||||
### Document Endpoints
|
||||
- GET/POST/PUT/DELETE /api/documents
|
||||
- GET /api/documents/:id
|
||||
|
||||
### Video Endpoints
|
||||
- GET/POST/PUT/DELETE /api/videos
|
||||
- GET /api/videos/:id
|
||||
- POST /api/videos/:id/subtitles
|
||||
|
||||
### Contact Endpoints
|
||||
- POST /api/contact
|
||||
- GET /api/contact
|
||||
- PUT /api/contact/:id
|
||||
- POST /api/contact/:id/respond
|
||||
|
||||
## Accessibility Implementation
|
||||
|
||||
- WCAG 2.2 AA compliance target
|
||||
- Semantic HTML structure
|
||||
- ARIA attributes for interactive components
|
||||
- Keyboard navigation support
|
||||
- Screen reader considerations
|
||||
- Color contrast compliance
|
||||
- Reduced motion support
|
||||
- DeafBlind/Blind user accessibility
|
||||
- ASL video content with captions and transcripts
|
||||
- Focus management
|
||||
- Reduced motion option
|
||||
- Video subtitle support
|
||||
- Text resizing support
|
||||
|
||||
### Browser Support
|
||||
- Modern browsers (Chrome, Firefox, Safari, Edge)
|
||||
- IE not supported
|
||||
- Mobile browsers (iOS Safari, Chrome Android)
|
||||
## Security Measures
|
||||
|
||||
### Performance Targets
|
||||
- First contentful paint < 1.5s
|
||||
- Time to interactive < 3.5s
|
||||
- Core Web Vitals compliance
|
||||
- Optimized image delivery
|
||||
- Minimal JavaScript overhead
|
||||
|
||||
### Security Considerations
|
||||
- HTTPS-only in production
|
||||
- Content Security Policy headers
|
||||
- JWT authentication with secure cookies
|
||||
- Input sanitization and validation
|
||||
- Authentication with JWTs and HTTP-only cookies
|
||||
- Role-based access control
|
||||
- Input validation
|
||||
- CSRF protection
|
||||
- Rate limiting (planned)
|
||||
|
||||
## Additional Notes
|
||||
- Custom CSS variables for theming
|
||||
- Dark mode support via media query
|
||||
- Mobile-first responsive design
|
||||
- Component-based architecture
|
||||
- Accessibility-first approach
|
||||
- Server-side rendering for SEO
|
||||
- Content Security Policy
|
||||
- Secure HTTP headers
|
||||
- Rate limiting
|
||||
- Data sanitization
|
||||
- Secure password handling
|
||||
|
|
|
|||
459
frontend/src/app/admin/content/page.tsx
Normal file
459
frontend/src/app/admin/content/page.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Page Filter Component
|
||||
const PageFilter = ({
|
||||
onFilterChange
|
||||
}: {
|
||||
onFilterChange: (filter: { status: string; search: string }) => void
|
||||
}) => {
|
||||
const [status, setStatus] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleFilterChange = () => {
|
||||
onFilterChange({ status, search });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatus('all');
|
||||
setSearch('');
|
||||
onFilterChange({ status: 'all', search: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Filter Pages</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
placeholder="Search by title or slug"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFilterChange}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ContentPage() {
|
||||
const [filter, setFilter] = useState({ status: 'all', search: '' });
|
||||
|
||||
// Mock pages data - would come from API in a real implementation
|
||||
const allPages = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Home Page',
|
||||
slug: '/',
|
||||
metaDescription: 'Welcome to the Olathe Club of the Deaf, serving the deaf community in Olathe, Kansas.',
|
||||
status: 'published',
|
||||
lastUpdated: '2025-03-15',
|
||||
createdBy: 'Admin User',
|
||||
sections: 6
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'About Us',
|
||||
slug: '/about',
|
||||
metaDescription: 'Learn about the Olathe Club of the Deaf, our mission, history, and board members.',
|
||||
status: 'published',
|
||||
lastUpdated: '2025-02-20',
|
||||
createdBy: 'Admin User',
|
||||
sections: 4
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Events',
|
||||
slug: '/events',
|
||||
metaDescription: 'Check out upcoming events and activities with the Olathe Club of the Deaf.',
|
||||
status: 'published',
|
||||
lastUpdated: '2025-03-10',
|
||||
createdBy: 'Admin User',
|
||||
sections: 3
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Membership',
|
||||
slug: '/membership',
|
||||
metaDescription: 'Join the Olathe Club of the Deaf and become part of our vibrant community.',
|
||||
status: 'published',
|
||||
lastUpdated: '2025-02-28',
|
||||
createdBy: 'Admin User',
|
||||
sections: 5
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Contact Us',
|
||||
slug: '/contact',
|
||||
metaDescription: 'Get in touch with the Olathe Club of the Deaf. We would love to hear from you!',
|
||||
status: 'published',
|
||||
lastUpdated: '2025-02-15',
|
||||
createdBy: 'Admin User',
|
||||
sections: 2
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Privacy Policy',
|
||||
slug: '/privacy-policy',
|
||||
metaDescription: 'Our privacy policy details how we handle and protect your information.',
|
||||
status: 'published',
|
||||
lastUpdated: '2025-01-10',
|
||||
createdBy: 'Admin User',
|
||||
sections: 8
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Donate',
|
||||
slug: '/donate',
|
||||
metaDescription: 'Support the Olathe Club of the Deaf through donations.',
|
||||
status: 'published',
|
||||
lastUpdated: '2025-03-05',
|
||||
createdBy: 'Admin User',
|
||||
sections: 4
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Summer Programs',
|
||||
slug: '/summer-programs',
|
||||
metaDescription: 'Summer programs and activities for deaf youth in Olathe.',
|
||||
status: 'draft',
|
||||
lastUpdated: '2025-03-18',
|
||||
createdBy: 'Admin User',
|
||||
sections: 3
|
||||
},
|
||||
];
|
||||
|
||||
// Filter pages based on current filter settings
|
||||
const filteredPages = allPages.filter(page => {
|
||||
if (filter.status !== 'all' && page.status !== filter.status) return false;
|
||||
|
||||
if (filter.search) {
|
||||
const searchLower = filter.search.toLowerCase();
|
||||
if (!page.title.toLowerCase().includes(searchLower) &&
|
||||
!page.slug.toLowerCase().includes(searchLower)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilter: { status: string; search: string }) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Content Management</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage website content, page sections, and metadata.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Link
|
||||
href="/admin/content/create"
|
||||
className="btn-primary inline-flex items-center px-4 py-2 text-sm font-medium rounded-md"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create New Page
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter component */}
|
||||
<PageFilter onFilterChange={handleFilterChange} />
|
||||
|
||||
{/* Pages table */}
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Page
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
URL
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Last Updated
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{filteredPages.map((page) => (
|
||||
<tr key={page.id}>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{page.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{page.sections} sections • Created by {page.createdBy}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900 dark:text-white font-mono">{page.slug}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 line-clamp-1">{page.metaDescription}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{formatDate(page.lastUpdated)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
page.status === 'published'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
}`}>
|
||||
{page.status.charAt(0).toUpperCase() + page.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={page.slug} target="_blank" className="text-primary hover:text-primary-dark">
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/admin/content/${page.id}/edit`} className="text-primary hover:text-primary-dark">
|
||||
Edit
|
||||
</Link>
|
||||
{page.status === 'published' ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Unpublish page: ${page.title}`);
|
||||
}}
|
||||
className="text-yellow-600 hover:text-yellow-800 dark:text-yellow-400 dark:hover:text-yellow-300"
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Publish page: ${page.title}`);
|
||||
}}
|
||||
className="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
{page.id !== '1' && page.id !== '2' && page.id !== '3' && page.id !== '4' && page.id !== '5' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Delete page: ${page.title}`);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredPages.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No pages found matching the current filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WYSIWYG Editor Preview */}
|
||||
<div className="mt-12">
|
||||
<h2 className="text-xl font-semibold mb-6">Page Editor Preview</h2>
|
||||
<div className="card p-8 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Page Title"
|
||||
className="w-full text-2xl font-bold bg-transparent border-0 border-b border-gray-300 dark:border-gray-600 focus:outline-none focus:border-primary dark:focus:border-primary px-0 py-2"
|
||||
defaultValue="New Page Title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-300 dark:border-gray-600 rounded-md mb-6">
|
||||
{/* Mock toolbar */}
|
||||
<div className="flex flex-wrap items-center p-2 gap-1 border-b border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700">
|
||||
<button className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h10M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-1"></span>
|
||||
<button className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-600 font-bold">B</button>
|
||||
<button className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-600 italic">I</button>
|
||||
<button className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-600 underline">U</button>
|
||||
<span className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-1"></span>
|
||||
<button className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor content */}
|
||||
<div className="p-4 min-h-[200px] bg-white dark:bg-gray-700">
|
||||
<p className="mb-4">This is a preview of the WYSIWYG editor. In the actual implementation, this would be a fully functional content editor with formatting options, image uploads, and more.</p>
|
||||
<p>The editor would support various formatting options like <strong>bold</strong>, <em>italic</em>, and <u>underline</u> text, as well as lists, links, and images.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
URL Path
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-sm">
|
||||
/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
className="focus:ring-primary focus:border-primary flex-1 block w-full rounded-none rounded-r-md sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700"
|
||||
placeholder="page-url-path"
|
||||
defaultValue="new-page"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:outline-none focus:ring-primary focus:border-primary sm:text-sm rounded-md"
|
||||
defaultValue="draft"
|
||||
>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="meta-description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Meta Description (for SEO)
|
||||
</label>
|
||||
<textarea
|
||||
id="meta-description"
|
||||
rows={3}
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
placeholder="Brief description of the page for search engines (150-160 characters recommended)"
|
||||
></textarea>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Write a compelling description to improve search engine rankings and click-through rates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Save as Draft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Publish Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/app/admin/dashboard/layout.tsx
Normal file
55
frontend/src/app/admin/dashboard/layout.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import AdminSidebar from '../../../components/admin/AdminSidebar';
|
||||
|
||||
export default function AdminDashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// For a real app, add auth check here to prevent unauthorized access
|
||||
// const { data: session, status } = useSession()
|
||||
// if (status === "loading") return <LoadingSpinner />
|
||||
// if (status === "unauthenticated") redirect('/admin/login')
|
||||
|
||||
return (
|
||||
<div className="h-screen flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 md:block hidden">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar (hidden by default) */}
|
||||
<div className="md:hidden">
|
||||
{/* We'd implement a mobile menu here */}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col bg-gray-50 dark:bg-gray-900 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm">
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Dashboard
|
||||
</h1>
|
||||
|
||||
{/* User menu or mobile menu button would go here */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Admin User
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
frontend/src/app/admin/dashboard/page.tsx
Normal file
266
frontend/src/app/admin/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Dashboard | Olathe Club of the Deaf',
|
||||
description: 'Admin dashboard for the Olathe Club of the Deaf website',
|
||||
};
|
||||
|
||||
// Stats Card Component
|
||||
const StatCard = ({ title, value, icon, href }: { title: string; value: string; icon: React.ReactNode; href: string }) => (
|
||||
<Link href={href} className="card p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-primary-light bg-opacity-20 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="ml-5">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{title}</p>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
// Simple icons
|
||||
const MembersIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EventsIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ContactIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VideosIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
// In a real app, these would be fetched from the API
|
||||
const stats = {
|
||||
members: "47",
|
||||
activeMembers: "41",
|
||||
events: "12",
|
||||
videos: "8",
|
||||
documents: "15",
|
||||
unreadContacts: "3"
|
||||
};
|
||||
|
||||
// Upcoming events - in a real app, these would be fetched from the API
|
||||
const upcomingEvents = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Monthly Social',
|
||||
date: 'Apr 15, 2025',
|
||||
location: 'Olathe Community Center',
|
||||
status: 'Published'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'ASL Workshop',
|
||||
date: 'Apr 22, 2025',
|
||||
location: 'OCD Meeting Room',
|
||||
status: 'Draft'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Board Meeting',
|
||||
date: 'May 5, 2025',
|
||||
location: 'Zoom Meeting',
|
||||
status: 'Published'
|
||||
}
|
||||
];
|
||||
|
||||
// Membership expirations - in a real app, these would be fetched from the API
|
||||
const expiringMemberships = [
|
||||
{
|
||||
id: '101',
|
||||
name: 'John Smith',
|
||||
expiryDate: 'Apr 30, 2025',
|
||||
type: 'Regular',
|
||||
status: 'Expiring Soon'
|
||||
},
|
||||
{
|
||||
id: '102',
|
||||
name: 'Sarah Johnson',
|
||||
expiryDate: 'May 15, 2025',
|
||||
type: 'Family',
|
||||
status: 'Expiring Soon'
|
||||
},
|
||||
{
|
||||
id: '103',
|
||||
name: 'Robert Lee',
|
||||
expiryDate: 'Mar 28, 2025',
|
||||
type: 'Regular',
|
||||
status: 'Expired'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-6">Overview</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Total Members"
|
||||
value={stats.members}
|
||||
icon={<MembersIcon />}
|
||||
href="/admin/members"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Members"
|
||||
value={stats.activeMembers}
|
||||
icon={<MembersIcon />}
|
||||
href="/admin/members/active"
|
||||
/>
|
||||
<StatCard
|
||||
title="Upcoming Events"
|
||||
value={stats.events}
|
||||
icon={<EventsIcon />}
|
||||
href="/admin/events"
|
||||
/>
|
||||
<StatCard
|
||||
title="Unread Messages"
|
||||
value={stats.unreadContacts}
|
||||
icon={<ContactIcon />}
|
||||
href="/admin/contact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Upcoming Events */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Upcoming Events</h3>
|
||||
<Link href="/admin/events" className="text-primary hover:text-primary-dark text-sm font-medium">
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{upcomingEvents.map((event) => (
|
||||
<div key={event.id} className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{event.title}</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{event.date} • {event.location}</p>
|
||||
</div>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
event.status === 'Published'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
}`}>
|
||||
{event.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href="/admin/events/create"
|
||||
className="btn-primary inline-flex items-center text-sm font-medium py-2 px-4 rounded-md"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Event
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Membership Expirations */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Membership Expirations</h3>
|
||||
<Link href="/admin/members" className="text-primary hover:text-primary-dark text-sm font-medium">
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{expiringMemberships.map((member) => (
|
||||
<div key={member.id} className="py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{member.name}</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{member.expiryDate} • {member.type}</p>
|
||||
</div>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
member.status === 'Expired'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
}`}>
|
||||
{member.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href="/admin/members/notify"
|
||||
className="btn-primary inline-flex items-center text-sm font-medium py-2 px-4 rounded-md"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Send Renewal Notices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold mb-4">Quick Actions</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Link href="/admin/events/create" className="card p-4 flex items-center hover:shadow-md transition-shadow">
|
||||
<div className="mr-3 text-primary">
|
||||
<EventsIcon />
|
||||
</div>
|
||||
<span className="text-sm font-medium">New Event</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/members/add" className="card p-4 flex items-center hover:shadow-md transition-shadow">
|
||||
<div className="mr-3 text-primary">
|
||||
<MembersIcon />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Add Member</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/videos/upload" className="card p-4 flex items-center hover:shadow-md transition-shadow">
|
||||
<div className="mr-3 text-primary">
|
||||
<VideosIcon />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Upload Video</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/admin/content/edit" className="card p-4 flex items-center hover:shadow-md transition-shadow">
|
||||
<div className="mr-3 text-primary">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Edit Pages</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
491
frontend/src/app/admin/documents/page.tsx
Normal file
491
frontend/src/app/admin/documents/page.tsx
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Document Filter Component
|
||||
const DocumentFilter = ({
|
||||
onFilterChange
|
||||
}: {
|
||||
onFilterChange: (filter: { category: string; isPublic: string; search: string }) => void
|
||||
}) => {
|
||||
const [category, setCategory] = useState('all');
|
||||
const [isPublic, setIsPublic] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleFilterChange = () => {
|
||||
onFilterChange({ category, isPublic, search });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setCategory('all');
|
||||
setIsPublic('all');
|
||||
setSearch('');
|
||||
onFilterChange({ category: 'all', isPublic: 'all', search: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Filter Documents</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
<option value="minutes">Meeting Minutes</option>
|
||||
<option value="bylaws">Bylaws</option>
|
||||
<option value="forms">Forms</option>
|
||||
<option value="reports">Reports</option>
|
||||
<option value="newsletters">Newsletters</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="isPublic" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Visibility
|
||||
</label>
|
||||
<select
|
||||
id="isPublic"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.value)}
|
||||
>
|
||||
<option value="all">All Documents</option>
|
||||
<option value="public">Public Only</option>
|
||||
<option value="private">Private Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
placeholder="Search by title or description"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFilterChange}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [filter, setFilter] = useState({ category: 'all', isPublic: 'all', search: '' });
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
|
||||
// Mock documents data - would come from API in a real implementation
|
||||
const allDocuments = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Board Meeting Minutes - March 2025',
|
||||
description: 'Minutes from the March 10, 2025 board meeting',
|
||||
category: 'minutes',
|
||||
fileUrl: '/documents/minutes-march-2025.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '284 KB',
|
||||
uploadDate: '2025-03-15',
|
||||
lastModified: '2025-03-15',
|
||||
isPublic: false,
|
||||
tags: ['board', 'minutes', 'march', '2025']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'OCD Bylaws 2025',
|
||||
description: 'Updated bylaws for the Olathe Club of the Deaf',
|
||||
category: 'bylaws',
|
||||
fileUrl: '/documents/ocd-bylaws-2025.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '567 KB',
|
||||
uploadDate: '2025-01-05',
|
||||
lastModified: '2025-01-05',
|
||||
isPublic: true,
|
||||
tags: ['bylaws', 'rules', 'organization']
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Membership Application Form',
|
||||
description: 'Form for new membership applications',
|
||||
category: 'forms',
|
||||
fileUrl: '/documents/membership-form.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '125 KB',
|
||||
uploadDate: '2024-12-10',
|
||||
lastModified: '2025-01-15',
|
||||
isPublic: true,
|
||||
tags: ['membership', 'application', 'form']
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Annual Financial Report 2024',
|
||||
description: 'Financial report for the fiscal year 2024',
|
||||
category: 'reports',
|
||||
fileUrl: '/documents/financial-report-2024.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '1.2 MB',
|
||||
uploadDate: '2025-02-15',
|
||||
lastModified: '2025-02-15',
|
||||
isPublic: false,
|
||||
tags: ['financial', 'annual', 'report', '2024']
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Winter Newsletter 2025',
|
||||
description: 'Newsletter for winter 2025 with club updates and events',
|
||||
category: 'newsletters',
|
||||
fileUrl: '/documents/newsletter-winter-2025.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '3.4 MB',
|
||||
uploadDate: '2025-01-10',
|
||||
lastModified: '2025-01-10',
|
||||
isPublic: true,
|
||||
tags: ['newsletter', 'winter', '2025', 'events']
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Event Proposal Form',
|
||||
description: 'Form for members to propose new events',
|
||||
category: 'forms',
|
||||
fileUrl: '/documents/event-proposal-form.docx',
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
fileSize: '68 KB',
|
||||
uploadDate: '2024-11-20',
|
||||
lastModified: '2024-11-20',
|
||||
isPublic: true,
|
||||
tags: ['events', 'proposal', 'form']
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Board Meeting Minutes - February 2025',
|
||||
description: 'Minutes from the February 12, 2025 board meeting',
|
||||
category: 'minutes',
|
||||
fileUrl: '/documents/minutes-february-2025.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '312 KB',
|
||||
uploadDate: '2025-02-18',
|
||||
lastModified: '2025-02-18',
|
||||
isPublic: false,
|
||||
tags: ['board', 'minutes', 'february', '2025']
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Member Handbook',
|
||||
description: 'Comprehensive handbook for all OCD members',
|
||||
category: 'bylaws',
|
||||
fileUrl: '/documents/member-handbook.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
fileSize: '4.2 MB',
|
||||
uploadDate: '2025-01-05',
|
||||
lastModified: '2025-01-05',
|
||||
isPublic: true,
|
||||
tags: ['handbook', 'members', 'rules', 'guidelines']
|
||||
},
|
||||
];
|
||||
|
||||
// Filter documents based on current filter settings
|
||||
const filteredDocuments = allDocuments.filter(document => {
|
||||
if (filter.category !== 'all' && document.category !== filter.category) return false;
|
||||
|
||||
if (filter.isPublic !== 'all') {
|
||||
const isPublicBool = filter.isPublic === 'public';
|
||||
if (document.isPublic !== isPublicBool) return false;
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
const searchLower = filter.search.toLowerCase();
|
||||
if (!document.title.toLowerCase().includes(searchLower) &&
|
||||
!document.description.toLowerCase().includes(searchLower) &&
|
||||
!document.tags.some(tag => tag.toLowerCase().includes(searchLower))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilter: { category: string; isPublic: string; search: string }) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
// Handle bulk actions
|
||||
const handleBulkAction = (action: string) => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Perform ${action} on ${selectedDocuments.length} documents`);
|
||||
};
|
||||
|
||||
// Handle select all checkbox
|
||||
const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = event.target.checked;
|
||||
setSelectAll(checked);
|
||||
|
||||
if (checked) {
|
||||
setSelectedDocuments(filteredDocuments.map(doc => doc.id));
|
||||
} else {
|
||||
setSelectedDocuments([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle individual checkbox selection
|
||||
const handleSelectDocument = (documentId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedDocuments(prev => [...prev, documentId]);
|
||||
} else {
|
||||
setSelectedDocuments(prev => prev.filter(id => id !== documentId));
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Get file icon based on MIME type
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType.includes('pdf')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
} else if (mimeType.includes('word') || mimeType.includes('document')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
} else if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Documents</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage bylaws, meeting minutes, forms, and other documents.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Link
|
||||
href="/admin/documents/upload"
|
||||
className="btn-primary inline-flex items-center px-4 py-2 text-sm font-medium rounded-md"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
Upload Document
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter component */}
|
||||
<DocumentFilter onFilterChange={handleFilterChange} />
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedDocuments.length > 0 && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-md mb-6 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-200">
|
||||
{selectedDocuments.length} document{selectedDocuments.length > 1 ? 's' : ''} selected
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBulkAction('download')}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 dark:text-blue-100 dark:bg-blue-800 dark:hover:bg-blue-700"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents table */}
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="select-all"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
checked={selectAll}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<label htmlFor="select-all" className="sr-only">Select All</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Document
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Size
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Dates
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Visibility
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{filteredDocuments.map((document) => (
|
||||
<tr key={document.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={`select-${document.id}`}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
checked={selectedDocuments.includes(document.id)}
|
||||
onChange={(e) => handleSelectDocument(document.id, e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={`select-${document.id}`} className="sr-only">Select {document.title}</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 text-gray-500 dark:text-gray-400 mr-3">
|
||||
{getFileIcon(document.mimeType)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{document.title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{document.description}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{document.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white capitalize">{document.category}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{document.fileSize}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Uploaded: {formatDate(document.uploadDate)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Modified: {formatDate(document.lastModified)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${document.isPublic ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200'}`}>
|
||||
{document.isPublic ? 'Public' : 'Private'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<a
|
||||
href={document.fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary-dark"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
<Link href={`/admin/documents/${document.id}/edit`} className="text-primary hover:text-primary-dark">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Delete document: ${document.title}`);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredDocuments.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No documents found matching the current filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
336
frontend/src/app/admin/events/page.tsx
Normal file
336
frontend/src/app/admin/events/page.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Simple Filter Component
|
||||
const EventFilter = ({
|
||||
onFilterChange
|
||||
}: {
|
||||
onFilterChange: (filter: { status: string; category: string; search: string }) => void
|
||||
}) => {
|
||||
const [status, setStatus] = useState('all');
|
||||
const [category, setCategory] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleFilterChange = () => {
|
||||
onFilterChange({ status, category, search });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatus('all');
|
||||
setCategory('all');
|
||||
setSearch('');
|
||||
onFilterChange({ status: 'all', category: 'all', search: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Filter Events</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
<option value="social">Social</option>
|
||||
<option value="athletic">Athletic</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
placeholder="Search by title or location"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFilterChange}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function EventsPage() {
|
||||
const [filter, setFilter] = useState({ status: 'all', category: 'all', search: '' });
|
||||
|
||||
// Mock events data - would come from API in a real implementation
|
||||
const allEvents = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Monthly Social',
|
||||
date: 'Apr 15, 2025',
|
||||
time: '6:00 PM - 9:00 PM',
|
||||
location: 'Olathe Community Center',
|
||||
category: 'social',
|
||||
status: 'published',
|
||||
registrationRequired: true,
|
||||
registeredCount: 24
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'ASL Workshop',
|
||||
date: 'Apr 22, 2025',
|
||||
time: '10:00 AM - 12:00 PM',
|
||||
location: 'OCD Meeting Room',
|
||||
category: 'educational',
|
||||
status: 'draft',
|
||||
registrationRequired: true,
|
||||
registeredCount: 12
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Board Meeting',
|
||||
date: 'May 5, 2025',
|
||||
time: '7:00 PM - 8:30 PM',
|
||||
location: 'Zoom Meeting',
|
||||
category: 'board',
|
||||
status: 'published',
|
||||
registrationRequired: false,
|
||||
registeredCount: 0
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Summer Picnic',
|
||||
date: 'Jun 12, 2025',
|
||||
time: '12:00 PM - 4:00 PM',
|
||||
location: 'Olathe Park',
|
||||
category: 'social',
|
||||
status: 'draft',
|
||||
registrationRequired: true,
|
||||
registeredCount: 0
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Deaf Awareness Week Workshop',
|
||||
date: 'Sep 23, 2025',
|
||||
time: '6:30 PM - 8:30 PM',
|
||||
location: 'Olathe Public Library',
|
||||
category: 'educational',
|
||||
status: 'published',
|
||||
registrationRequired: true,
|
||||
registeredCount: 8
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Holiday Party',
|
||||
date: 'Dec 18, 2025',
|
||||
time: '7:00 PM - 10:00 PM',
|
||||
location: 'Olathe Community Center',
|
||||
category: 'social',
|
||||
status: 'draft',
|
||||
registrationRequired: true,
|
||||
registeredCount: 0
|
||||
},
|
||||
];
|
||||
|
||||
// Filter events based on current filter settings
|
||||
const filteredEvents = allEvents.filter(event => {
|
||||
if (filter.status !== 'all' && event.status !== filter.status) return false;
|
||||
if (filter.category !== 'all' && event.category !== filter.category) return false;
|
||||
if (filter.search && !event.title.toLowerCase().includes(filter.search.toLowerCase()) &&
|
||||
!event.location.toLowerCase().includes(filter.search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilter: { status: string; category: string; search: string }) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
// Function to get status badge styles
|
||||
const getStatusBadgeClasses = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200';
|
||||
case 'draft':
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
case 'archived':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
// Function to get category badge styles
|
||||
const getCategoryBadgeClasses = (category: string) => {
|
||||
switch (category) {
|
||||
case 'social':
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200';
|
||||
case 'educational':
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200';
|
||||
case 'board':
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
case 'athletic':
|
||||
return 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Events</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage club events, registrations, and schedules.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Link
|
||||
href="/admin/events/create"
|
||||
className="btn-primary inline-flex items-center px-4 py-2 text-sm font-medium rounded-md"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Create Event
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter component */}
|
||||
<EventFilter onFilterChange={handleFilterChange} />
|
||||
|
||||
{/* Events table */}
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Event
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Date & Time
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Registrations
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{filteredEvents.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{event.title}</div>
|
||||
<div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getCategoryBadgeClasses(event.category)}`}>
|
||||
{event.category.charAt(0).toUpperCase() + event.category.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{event.date}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{event.time}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{event.location}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClasses(event.status)}`}>
|
||||
{event.status.charAt(0).toUpperCase() + event.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{event.registrationRequired ? (
|
||||
<span>{event.registeredCount} registered</span>
|
||||
) : (
|
||||
<span>Not required</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/events/${event.id}`} className="text-primary hover:text-primary-dark">
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/admin/events/${event.id}/edit`} className="text-primary hover:text-primary-dark">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Delete functionality would go here
|
||||
alert(`Delete event: ${event.title}`);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredEvents.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No events found matching the current filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/app/admin/layout.tsx
Normal file
19
frontend/src/app/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin Portal | Olathe Club of the Deaf',
|
||||
description: 'Secure administration portal for the Olathe Club of the Deaf website',
|
||||
};
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
frontend/src/app/admin/login/page.tsx
Normal file
152
frontend/src/app/admin/login/page.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
// Note: This can't be exported from a client component
|
||||
// The metadata would need to be in a separate layout.tsx file
|
||||
const metadata = {
|
||||
title: 'Admin Login | Olathe Club of the Deaf',
|
||||
description: 'Secure login for Olathe Club of the Deaf administrators',
|
||||
};
|
||||
|
||||
const LoginPage = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// This will be replaced with actual API call in the future
|
||||
// For now, it's just a mock implementation
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message || 'Login failed');
|
||||
}
|
||||
|
||||
// Login successful, redirect to admin dashboard
|
||||
router.push('/admin/dashboard');
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
setError(err instanceof Error ? err.message : 'An unknown error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8 flex items-center justify-center">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h1 className="mt-6 text-center text-3xl font-bold text-gray-900 dark:text-white">
|
||||
OCD Admin Portal
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Secure login for authorized administrators
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-8">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-100 dark:bg-red-900 border border-red-400 text-red-700 dark:text-red-100 px-4 py-3 rounded relative" role="alert">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-t-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-white placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-b-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-white placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link
|
||||
href="/admin/forgot-password"
|
||||
className="text-primary hover:text-primary-dark"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary ${loading ? 'opacity-70 cursor-not-allowed' : ''}`}
|
||||
aria-busy={loading}
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<Link href="/" className="text-primary hover:text-primary-dark">
|
||||
Return to public site
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
456
frontend/src/app/admin/members/page.tsx
Normal file
456
frontend/src/app/admin/members/page.tsx
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Member Filter Component
|
||||
const MemberFilter = ({
|
||||
onFilterChange
|
||||
}: {
|
||||
onFilterChange: (filter: { status: string; type: string; search: string }) => void
|
||||
}) => {
|
||||
const [status, setStatus] = useState('all');
|
||||
const [type, setType] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleFilterChange = () => {
|
||||
onFilterChange({ status, type, search });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatus('all');
|
||||
setType('all');
|
||||
setSearch('');
|
||||
onFilterChange({ status: 'all', type: 'all', search: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Filter Members</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="pending">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Membership Type
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="regular">Regular</option>
|
||||
<option value="family">Family</option>
|
||||
<option value="lifetime">Lifetime</option>
|
||||
<option value="honorary">Honorary</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
placeholder="Search by name, email, or phone"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFilterChange}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Bulk actions component
|
||||
const BulkActions = ({ selectedMembers, onAction }: { selectedMembers: string[], onAction: (action: string) => void }) => {
|
||||
if (selectedMembers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900 p-4 rounded-md mb-6 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-200">
|
||||
{selectedMembers.length} member{selectedMembers.length > 1 ? 's' : ''} selected
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('email')}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 dark:text-blue-100 dark:bg-blue-800 dark:hover:bg-blue-700"
|
||||
>
|
||||
Email Selected
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('renew')}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark"
|
||||
>
|
||||
Renew Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MembersPage() {
|
||||
const [filter, setFilter] = useState({ status: 'all', type: 'all', search: '' });
|
||||
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
|
||||
// Mock members data - would come from API in a real implementation
|
||||
const allMembers = [
|
||||
{
|
||||
id: '1',
|
||||
firstName: 'John',
|
||||
lastName: 'Smith',
|
||||
email: 'john.smith@example.com',
|
||||
phone: '(913) 555-1234',
|
||||
membershipType: 'regular',
|
||||
joinDate: '2019-06-15',
|
||||
expirationDate: '2025-06-15',
|
||||
status: 'active',
|
||||
lastRenewalDate: '2024-06-10',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
firstName: 'Sarah',
|
||||
lastName: 'Johnson',
|
||||
email: 'sarah.j@example.com',
|
||||
phone: '(913) 555-5678',
|
||||
membershipType: 'family',
|
||||
joinDate: '2021-03-22',
|
||||
expirationDate: '2025-03-22',
|
||||
status: 'active',
|
||||
lastRenewalDate: '2024-03-15',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
firstName: 'Robert',
|
||||
lastName: 'Lee',
|
||||
email: 'robert.lee@example.com',
|
||||
phone: '(913) 555-9012',
|
||||
membershipType: 'regular',
|
||||
joinDate: '2020-11-05',
|
||||
expirationDate: '2025-03-28',
|
||||
status: 'expired',
|
||||
lastRenewalDate: '2023-03-25',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
firstName: 'Emily',
|
||||
lastName: 'Davis',
|
||||
email: 'emily.d@example.com',
|
||||
phone: '(913) 555-3456',
|
||||
membershipType: 'lifetime',
|
||||
joinDate: '2018-09-30',
|
||||
expirationDate: null,
|
||||
status: 'active',
|
||||
lastRenewalDate: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
firstName: 'Michael',
|
||||
lastName: 'Wilson',
|
||||
email: 'michael.w@example.com',
|
||||
phone: '(913) 555-7890',
|
||||
membershipType: 'regular',
|
||||
joinDate: '2022-01-15',
|
||||
expirationDate: '2025-01-15',
|
||||
status: 'active',
|
||||
lastRenewalDate: '2024-01-10',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
firstName: 'Jennifer',
|
||||
lastName: 'Brown',
|
||||
email: 'jennifer.b@example.com',
|
||||
phone: '(913) 555-2345',
|
||||
membershipType: 'family',
|
||||
joinDate: '2021-07-20',
|
||||
expirationDate: '2023-07-20',
|
||||
status: 'expired',
|
||||
lastRenewalDate: '2022-07-15',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
firstName: 'William',
|
||||
lastName: 'Taylor',
|
||||
email: 'will.t@example.com',
|
||||
phone: '(913) 555-6789',
|
||||
membershipType: 'honorary',
|
||||
joinDate: '2020-05-10',
|
||||
expirationDate: null,
|
||||
status: 'active',
|
||||
lastRenewalDate: null,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
firstName: 'Lisa',
|
||||
lastName: 'Anderson',
|
||||
email: 'lisa.a@example.com',
|
||||
phone: '(913) 555-0123',
|
||||
membershipType: 'regular',
|
||||
joinDate: '2023-02-28',
|
||||
expirationDate: '2024-02-28',
|
||||
status: 'pending',
|
||||
lastRenewalDate: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Filter members based on current filter settings
|
||||
const filteredMembers = allMembers.filter(member => {
|
||||
if (filter.status !== 'all' && member.status !== filter.status) return false;
|
||||
if (filter.type !== 'all' && member.membershipType !== filter.type) return false;
|
||||
if (filter.search) {
|
||||
const searchLower = filter.search.toLowerCase();
|
||||
const fullName = `${member.firstName} ${member.lastName}`.toLowerCase();
|
||||
|
||||
if (!fullName.includes(searchLower) &&
|
||||
!member.email.toLowerCase().includes(searchLower) &&
|
||||
!member.phone.includes(filter.search)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilter: { status: string; type: string; search: string }) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
// Handle bulk actions
|
||||
const handleBulkAction = (action: string) => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Perform ${action} on ${selectedMembers.length} members`);
|
||||
};
|
||||
|
||||
// Handle select all checkbox
|
||||
const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = event.target.checked;
|
||||
setSelectAll(checked);
|
||||
|
||||
if (checked) {
|
||||
setSelectedMembers(filteredMembers.map(member => member.id));
|
||||
} else {
|
||||
setSelectedMembers([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle individual checkbox selection
|
||||
const handleSelectMember = (memberId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedMembers(prev => [...prev, memberId]);
|
||||
} else {
|
||||
setSelectedMembers(prev => prev.filter(id => id !== memberId));
|
||||
}
|
||||
};
|
||||
|
||||
// Function to get status badge styles
|
||||
const getStatusBadgeClasses = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200';
|
||||
case 'expired':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'N/A';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Members</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage memberships, track expirations, and communicate with members.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Link
|
||||
href="/admin/members/add"
|
||||
className="btn-primary inline-flex items-center px-4 py-2 text-sm font-medium rounded-md"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Member
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter component */}
|
||||
<MemberFilter onFilterChange={handleFilterChange} />
|
||||
|
||||
{/* Bulk actions */}
|
||||
<BulkActions
|
||||
selectedMembers={selectedMembers}
|
||||
onAction={handleBulkAction}
|
||||
/>
|
||||
|
||||
{/* Members table */}
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="select-all"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
checked={selectAll}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<label htmlFor="select-all" className="sr-only">Select All</label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Member
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Contact
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Membership
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Dates
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{filteredMembers.map((member) => (
|
||||
<tr key={member.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={`select-${member.id}`}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
checked={selectedMembers.includes(member.id)}
|
||||
onChange={(e) => handleSelectMember(member.id, e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={`select-${member.id}`} className="sr-only">Select {member.firstName} {member.lastName}</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{member.firstName} {member.lastName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{member.email}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{member.phone}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white capitalize">{member.membershipType}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Joined: {formatDate(member.joinDate)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Expires: {formatDate(member.expirationDate)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClasses(member.status)}`}>
|
||||
{member.status.charAt(0).toUpperCase() + member.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/members/${member.id}`} className="text-primary hover:text-primary-dark">
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/admin/members/${member.id}/edit`} className="text-primary hover:text-primary-dark">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// In a real app, this would call an API
|
||||
alert(`Send renewal notice to: ${member.firstName} ${member.lastName}`);
|
||||
}}
|
||||
className="text-primary hover:text-primary-dark"
|
||||
>
|
||||
Renew
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No members found matching the current filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
frontend/src/app/admin/page.tsx
Normal file
9
frontend/src/app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminPage() {
|
||||
// Redirect to login page
|
||||
redirect('/admin/login');
|
||||
|
||||
// This won't be rendered, but is needed for TypeScript
|
||||
return null;
|
||||
}
|
||||
621
frontend/src/app/admin/settings/page.tsx
Normal file
621
frontend/src/app/admin/settings/page.tsx
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
// Mock settings data - would be loaded from API in real implementation
|
||||
const [generalSettings, setGeneralSettings] = useState({
|
||||
siteName: 'Olathe Club of the Deaf',
|
||||
siteDescription: 'The official website for the Olathe Club of the Deaf, serving the deaf community in Olathe, Kansas.',
|
||||
contactEmail: 'info@olathedeafclub.com',
|
||||
notificationEmail: 'notifications@olathedeafclub.com',
|
||||
facebookUrl: 'https://facebook.com/olathedeafclub',
|
||||
instagramUrl: 'https://instagram.com/olathedeafclub',
|
||||
address: '123 Main Street, Olathe, KS 66061',
|
||||
googleMapsUrl: 'https://goo.gl/maps/exampleMapLink'
|
||||
});
|
||||
|
||||
const [emailSettings, setEmailSettings] = useState({
|
||||
smtpHost: 'smtp.gmail.com',
|
||||
smtpPort: '587',
|
||||
smtpUsername: 'system@olathedeafclub.com',
|
||||
smtpPassword: '••••••••••••',
|
||||
fromName: 'Olathe Club of the Deaf',
|
||||
fromEmail: 'system@olathedeafclub.com',
|
||||
replyToEmail: 'info@olathedeafclub.com'
|
||||
});
|
||||
|
||||
const [membershipSettings, setMembershipSettings] = useState({
|
||||
regularMembershipFee: '30',
|
||||
familyMembershipFee: '45',
|
||||
lifetimeMembershipFee: '500',
|
||||
expirationReminderDays: '30',
|
||||
automaticRenewalEnabled: false,
|
||||
sendWelcomeEmail: true,
|
||||
sendExpirationReminders: true,
|
||||
sendExpiredNotifications: true,
|
||||
notifyBoardOnExpirations: true
|
||||
});
|
||||
|
||||
const [accessibilitySettings, setAccessibilitySettings] = useState({
|
||||
highContrastModeEnabled: true,
|
||||
reducedMotionEnabled: true,
|
||||
defaultVideoSubtitles: true,
|
||||
defaultFontSize: 'medium',
|
||||
defaultASLVideoDuration: '3', // in minutes
|
||||
transcriptRequiredForVideos: true,
|
||||
autoTranslateEnabled: false
|
||||
});
|
||||
|
||||
// Handle form submit
|
||||
const handleSubmit = (e: React.FormEvent, section: string) => {
|
||||
e.preventDefault();
|
||||
// In a real app, this would save settings to the API
|
||||
alert(`Saved ${section} settings`);
|
||||
};
|
||||
|
||||
// Handle changes for each settings section
|
||||
const handleGeneralChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setGeneralSettings(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setEmailSettings(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleMembershipChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target as HTMLInputElement;
|
||||
const newValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;
|
||||
setMembershipSettings(prev => ({ ...prev, [name]: newValue }));
|
||||
};
|
||||
|
||||
const handleAccessibilityChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target as HTMLInputElement;
|
||||
const newValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value;
|
||||
setAccessibilitySettings(prev => ({ ...prev, [name]: newValue }));
|
||||
};
|
||||
|
||||
// Test email connection
|
||||
const testEmailConnection = async () => {
|
||||
// In a real app, this would send a test email via API
|
||||
alert('Test email sent successfully!');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Settings</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure website settings, email notifications, and accessibility options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<ul className="flex flex-wrap -mb-px text-sm font-medium text-center">
|
||||
<li className="mr-2">
|
||||
<a href="#general" className="inline-block p-4 border-b-2 border-primary text-primary rounded-t-lg active">
|
||||
General
|
||||
</a>
|
||||
</li>
|
||||
<li className="mr-2">
|
||||
<a href="#email" className="inline-block p-4 border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300 rounded-t-lg">
|
||||
Email
|
||||
</a>
|
||||
</li>
|
||||
<li className="mr-2">
|
||||
<a href="#membership" className="inline-block p-4 border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300 rounded-t-lg">
|
||||
Membership
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#accessibility" className="inline-block p-4 border-b-2 border-transparent hover:text-gray-600 hover:border-gray-300 dark:hover:text-gray-300 rounded-t-lg">
|
||||
Accessibility
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Settings Sections */}
|
||||
<div className="space-y-12">
|
||||
{/* General Settings */}
|
||||
<section id="general" className="card p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-6">General Settings</h2>
|
||||
<form onSubmit={(e) => handleSubmit(e, 'general')}>
|
||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="siteName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Site Name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="siteName"
|
||||
id="siteName"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={generalSettings.siteName}
|
||||
onChange={handleGeneralChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="contactEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Contact Email
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="email"
|
||||
name="contactEmail"
|
||||
id="contactEmail"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={generalSettings.contactEmail}
|
||||
onChange={handleGeneralChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<label htmlFor="siteDescription" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Site Description
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<textarea
|
||||
id="siteDescription"
|
||||
name="siteDescription"
|
||||
rows={3}
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={generalSettings.siteDescription}
|
||||
onChange={handleGeneralChange}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Brief description of your organization. This may be used in search results and social media shares.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="notificationEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Notification Email
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="email"
|
||||
name="notificationEmail"
|
||||
id="notificationEmail"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={generalSettings.notificationEmail}
|
||||
onChange={handleGeneralChange}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Notifications about system events will be sent to this email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Organization Address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="address"
|
||||
id="address"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={generalSettings.address}
|
||||
onChange={handleGeneralChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="facebookUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Facebook URL
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="url"
|
||||
name="facebookUrl"
|
||||
id="facebookUrl"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={generalSettings.facebookUrl}
|
||||
onChange={handleGeneralChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="instagramUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Instagram URL
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="url"
|
||||
name="instagramUrl"
|
||||
id="instagramUrl"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={generalSettings.instagramUrl}
|
||||
onChange={handleGeneralChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<label htmlFor="googleMapsUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Google Maps URL
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="url"
|
||||
name="googleMapsUrl"
|
||||
id="googleMapsUrl"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={generalSettings.googleMapsUrl}
|
||||
onChange={handleGeneralChange}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
URL to your Google Maps location to display on the contact page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Email Settings */}
|
||||
<section id="email" className="card p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-6">Email Settings</h2>
|
||||
<form onSubmit={(e) => handleSubmit(e, 'email')}>
|
||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="smtpHost" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
SMTP Host
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="smtpHost"
|
||||
id="smtpHost"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={emailSettings.smtpHost}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="smtpPort" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
SMTP Port
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="smtpPort"
|
||||
id="smtpPort"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={emailSettings.smtpPort}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="smtpUsername" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
SMTP Username
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="smtpUsername"
|
||||
id="smtpUsername"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={emailSettings.smtpUsername}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="smtpPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
SMTP Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="password"
|
||||
name="smtpPassword"
|
||||
id="smtpPassword"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={emailSettings.smtpPassword}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="fromName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
From Name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="fromName"
|
||||
id="fromName"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={emailSettings.fromName}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
The name that will appear in the From field of emails.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="fromEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
From Email
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="email"
|
||||
name="fromEmail"
|
||||
id="fromEmail"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={emailSettings.fromEmail}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="replyToEmail" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reply-To Email
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="email"
|
||||
name="replyToEmail"
|
||||
id="replyToEmail"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={emailSettings.replyToEmail}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={testEmailConnection}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Test Email Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Membership Settings */}
|
||||
<section id="membership" className="card p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-6">Membership Settings</h2>
|
||||
<form onSubmit={(e) => handleSubmit(e, 'membership')}>
|
||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="regularMembershipFee" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Regular Membership Fee ($)
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="regularMembershipFee"
|
||||
id="regularMembershipFee"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={membershipSettings.regularMembershipFee}
|
||||
onChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="familyMembershipFee" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Family Membership Fee ($)
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="familyMembershipFee"
|
||||
id="familyMembershipFee"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={membershipSettings.familyMembershipFee}
|
||||
onChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="lifetimeMembershipFee" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Lifetime Membership Fee ($)
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="lifetimeMembershipFee"
|
||||
id="lifetimeMembershipFee"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={membershipSettings.lifetimeMembershipFee}
|
||||
onChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="expirationReminderDays" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Days Before Expiration to Send Reminder
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="expirationReminderDays"
|
||||
id="expirationReminderDays"
|
||||
className="shadow-sm focus:ring-primary focus:border-primary block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-gray-700 rounded-md"
|
||||
value={membershipSettings.expirationReminderDays}
|
||||
onChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="automaticRenewalEnabled"
|
||||
name="automaticRenewalEnabled"
|
||||
type="checkbox"
|
||||
className="focus:ring-primary h-4 w-4 text-primary border-gray-300 rounded"
|
||||
checked={membershipSettings.automaticRenewalEnabled}
|
||||
onChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="automaticRenewalEnabled" className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Enable Automatic Renewal
|
||||
</label>
|
||||
<p className="text-gray-500 dark:text-gray-400">When enabled, members will be asked if they want to set up automatic renewal.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="sendWelcomeEmail"
|
||||
name="sendWelcomeEmail"
|
||||
type="checkbox"
|
||||
className="focus:ring-primary h-4 w-4 text-primary border-gray-300 rounded"
|
||||
checked={membershipSettings.sendWelcomeEmail}
|
||||
onChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="sendWelcomeEmail" className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Send Welcome Email
|
||||
</label>
|
||||
<p className="text-gray-500 dark:text-gray-400">Send welcome email to new members when they join.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="sendExpirationReminders"
|
||||
name="sendExpirationReminders"
|
||||
type="checkbox"
|
||||
className="focus:ring-primary h-4 w-4 text-primary border-gray-300 rounded"
|
||||
checked={membershipSettings.sendExpirationReminders}
|
||||
onChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="sendExpirationReminders" className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Send Expiration Reminders
|
||||
</label>
|
||||
<p className="text-gray-500 dark:text-gray-400">Send email reminders to members before their membership expires.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="sendExpiredNotifications"
|
||||
name="sendExpiredNotifications"
|
||||
type="checkbox"
|
||||
className="focus:ring-primary h-4 w-4 text-primary border-gray-300 rounded"
|
||||
checked={membershipSettings.sendExpiredNotifications}
|
||||
onChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="sendExpiredNotifications" className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Send Expired Notifications
|
||||
</label>
|
||||
<p className="text-gray-500 dark:text-gray-400">Send notification to members when their membership has expired.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="notifyBoardOnExpirations"
|
||||
name="notifyBoardOnExpirations"
|
||||
type="checkbox"
|
||||
className="focus:ring-primary h-4 w-4 text-primary border-gray-300 rounded"
|
||||
checked={membershipSettings.notifyBoardOnExpirations}
|
||||
onChange={handleMembershipChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="notifyBoardOnExpirations" className="font-medium text-gray-700 dark:text-gray-300">
|
||||
Notify Board on Expirations
|
||||
</label>
|
||||
<p className="text-gray-500 dark:text-gray-400">Send notification to board members when memberships expire.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-700 py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2
|
||||
326
frontend/src/app/admin/videos/page.tsx
Normal file
326
frontend/src/app/admin/videos/page.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Video Filter Component
|
||||
const VideoFilter = ({
|
||||
onFilterChange
|
||||
}: {
|
||||
onFilterChange: (filter: { status: string; category: string; search: string }) => void
|
||||
}) => {
|
||||
const [status, setStatus] = useState('all');
|
||||
const [category, setCategory] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleFilterChange = () => {
|
||||
onFilterChange({ status, category, search });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatus('all');
|
||||
setCategory('all');
|
||||
setSearch('');
|
||||
onFilterChange({ status: 'all', category: 'all', search: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Filter Videos</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="processing">Processing</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
<option value="announcements">Announcements</option>
|
||||
<option value="events">Events</option>
|
||||
<option value="tutorials">Tutorials</option>
|
||||
<option value="stories">Stories</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
className="block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
placeholder="Search by title or description"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFilterChange}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function VideosPage() {
|
||||
const [filter, setFilter] = useState({ status: 'all', category: 'all', search: '' });
|
||||
|
||||
// Mock videos data - would come from API in a real implementation
|
||||
const allVideos = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Welcome to OCD',
|
||||
description: 'An introduction to the Olathe Club of the Deaf',
|
||||
duration: '2:35',
|
||||
uploadDate: '2025-01-15',
|
||||
category: 'announcements',
|
||||
status: 'published',
|
||||
thumbnailUrl: '/images/video-thumb-1.jpg',
|
||||
hasSubtitles: true,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'ASL Tutorial: Basic Greetings',
|
||||
description: 'Learn basic greetings in American Sign Language',
|
||||
duration: '4:12',
|
||||
uploadDate: '2025-02-03',
|
||||
category: 'tutorials',
|
||||
status: 'published',
|
||||
thumbnailUrl: '/images/video-thumb-2.jpg',
|
||||
hasSubtitles: true,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Summer Picnic 2024 Recap',
|
||||
description: 'Highlights from our annual summer picnic',
|
||||
duration: '3:45',
|
||||
uploadDate: '2024-07-25',
|
||||
category: 'events',
|
||||
status: 'published',
|
||||
thumbnailUrl: '/images/video-thumb-3.jpg',
|
||||
hasSubtitles: true,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Board Meeting Announcement - April 2025',
|
||||
description: 'Important information about the upcoming board meeting',
|
||||
duration: '1:30',
|
||||
uploadDate: '2025-03-15',
|
||||
category: 'announcements',
|
||||
status: 'draft',
|
||||
thumbnailUrl: '/images/video-thumb-4.jpg',
|
||||
hasSubtitles: false,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'ASL Story: The Tortoise and the Hare',
|
||||
description: 'A classic fable told in ASL',
|
||||
duration: '6:20',
|
||||
uploadDate: '2025-02-20',
|
||||
category: 'stories',
|
||||
status: 'published',
|
||||
thumbnailUrl: '/images/video-thumb-5.jpg',
|
||||
hasSubtitles: true,
|
||||
hasTranscript: true
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Membership Benefits Explained',
|
||||
description: 'Learn about the benefits of becoming an OCD member',
|
||||
duration: '3:10',
|
||||
uploadDate: '2025-03-01',
|
||||
category: 'announcements',
|
||||
status: 'processing',
|
||||
thumbnailUrl: '/images/video-thumb-6.jpg',
|
||||
hasSubtitles: false,
|
||||
hasTranscript: false
|
||||
},
|
||||
];
|
||||
|
||||
// Filter videos based on current filter settings
|
||||
const filteredVideos = allVideos.filter(video => {
|
||||
if (filter.status !== 'all' && video.status !== filter.status) return false;
|
||||
if (filter.category !== 'all' && video.category !== filter.category) return false;
|
||||
if (filter.search && !video.title.toLowerCase().includes(filter.search.toLowerCase()) &&
|
||||
!video.description.toLowerCase().includes(filter.search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilter: { status: string; category: string; search: string }) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
// Function to get status badge styles
|
||||
const getStatusBadgeClasses = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200';
|
||||
case 'draft':
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
case 'processing':
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Video card component
|
||||
const VideoCard = ({ video }: { video: typeof allVideos[0] }) => (
|
||||
<div className="card overflow-hidden h-full flex flex-col">
|
||||
{/* Thumbnail with duration */}
|
||||
<div className="relative bg-gray-200 dark:bg-gray-700 aspect-video">
|
||||
{/* In a real app, this would be an actual image */}
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
|
||||
{video.duration}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white line-clamp-1">{video.title}</h3>
|
||||
<span className={`ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getStatusBadgeClasses(video.status)}`}>
|
||||
{video.status.charAt(0).toUpperCase() + video.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">{video.description}</p>
|
||||
<div className="mt-2 flex items-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>Uploaded: {formatDate(video.uploadDate)}</span>
|
||||
</div>
|
||||
|
||||
{/* Accessibility status */}
|
||||
<div className="mt-2 flex items-center space-x-2 text-xs">
|
||||
<span className={`px-2 py-0.5 rounded-full ${video.hasSubtitles ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200'}`}>
|
||||
{video.hasSubtitles ? 'Has Subtitles' : 'No Subtitles'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full ${video.hasTranscript ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200'}`}>
|
||||
{video.hasTranscript ? 'Has Transcript' : 'No Transcript'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200">
|
||||
{video.category.charAt(0).toUpperCase() + video.category.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex justify-between">
|
||||
<Link href={`/admin/videos/${video.id}`} className="text-primary hover:text-primary-dark text-sm">
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/admin/videos/${video.id}/edit`} className="text-primary hover:text-primary-dark text-sm">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Delete functionality would go here
|
||||
alert(`Delete video: ${video.title}`);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Videos</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage ASL videos with subtitles and transcripts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0">
|
||||
<Link
|
||||
href="/admin/videos/upload"
|
||||
className="btn-primary inline-flex items-center px-4 py-2 text-sm font-medium rounded-md"
|
||||
>
|
||||
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
Upload Video
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter component */}
|
||||
<VideoFilter onFilterChange={handleFilterChange} />
|
||||
|
||||
{/* Videos grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredVideos.map((video) => (
|
||||
<VideoCard key={video.id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredVideos.length === 0 && (
|
||||
<div className="card p-6 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No videos found matching the current filters.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/src/app/api/auth/login/route.ts
Normal file
74
frontend/src/app/api/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Mock user data - in a real application, this would be fetched from a database
|
||||
const MOCK_USER = {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
// In a real app, this would be a hashed password, not plaintext
|
||||
password: 'password123',
|
||||
name: 'Admin User',
|
||||
role: 'admin',
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Parse the request body
|
||||
const body = await request.json();
|
||||
const { username, password } = body;
|
||||
|
||||
// Basic validation
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Username and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// In a real app, you would:
|
||||
// 1. Query the database for the user
|
||||
// 2. Compare the hashed password
|
||||
// 3. Create a JWT token
|
||||
// For this demo, we're using a mock user and simple comparison
|
||||
|
||||
if (username === MOCK_USER.username && password === MOCK_USER.password) {
|
||||
// Create session or JWT token here
|
||||
// For now, we'll just return a successful response with mock user data
|
||||
// In production, you would create a JWT token and set it as an HTTP-only cookie
|
||||
|
||||
// Create a new response
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: MOCK_USER.id,
|
||||
username: MOCK_USER.username,
|
||||
name: MOCK_USER.name,
|
||||
role: MOCK_USER.role,
|
||||
},
|
||||
});
|
||||
|
||||
// In a real app, set a secure HTTP-only cookie with the JWT
|
||||
// response.cookies.set({
|
||||
// name: 'auth_token',
|
||||
// value: 'MOCK_JWT_TOKEN',
|
||||
// httpOnly: true,
|
||||
// secure: process.env.NODE_ENV === 'production',
|
||||
// maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||
// path: '/',
|
||||
// });
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// If credentials don't match
|
||||
return NextResponse.json(
|
||||
{ message: 'Invalid username or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return NextResponse.json(
|
||||
{ message: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
131
frontend/src/components/admin/AdminSidebar.tsx
Normal file
131
frontend/src/components/admin/AdminSidebar.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
// Simple icons for admin navigation
|
||||
const DashboardIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EventsIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MembersIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ContentIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DocumentsIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VideosIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SettingsIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LogoutIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AdminSidebar = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', href: '/admin/dashboard', icon: <DashboardIcon /> },
|
||||
{ name: 'Events', href: '/admin/events', icon: <EventsIcon /> },
|
||||
{ name: 'Members', href: '/admin/members', icon: <MembersIcon /> },
|
||||
{ name: 'Content', href: '/admin/content', icon: <ContentIcon /> },
|
||||
{ name: 'Documents', href: '/admin/documents', icon: <DocumentsIcon /> },
|
||||
{ name: 'Videos', href: '/admin/videos', icon: <VideosIcon /> },
|
||||
{ name: 'Settings', href: '/admin/settings', icon: <SettingsIcon /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
|
||||
{/* Logo and site title */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<Link href="/admin/dashboard" className="flex items-center">
|
||||
<span className="text-xl font-semibold text-gray-900 dark:text-white">OCD Admin</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-4">
|
||||
<div className="px-2 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={`group flex items-center px-4 py-2 text-sm font-medium rounded-md ${
|
||||
isActive
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<span className={`mr-3 ${isActive ? 'text-white' : 'text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-300'}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Logout button at bottom */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
// In a real app, call logout API
|
||||
window.location.href = '/admin/login';
|
||||
}}
|
||||
className="w-full flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||
>
|
||||
<span className="mr-3 text-gray-500 dark:text-gray-400">
|
||||
<LogoutIcon />
|
||||
</span>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSidebar;
|
||||
|
|
@ -20,12 +20,12 @@ The Olathe Club of the Deaf (OCD) website will serve as a digital hub for the de
|
|||
- **Animation**: Framer Motion 11 for UI transitions
|
||||
- **Routing**: Next.js App Router with nested layouts
|
||||
- **Interactive Components**:
|
||||
- React Simple Maps 4.0 for location visualization
|
||||
- React Simple Maps 3.0.0 for location visualization
|
||||
- Custom video player with WebVTT support
|
||||
- React Intersection Observer 9.13 for scroll animations
|
||||
- React Intersection Observer 9.16.0 for scroll animations
|
||||
|
||||
### Backend Architecture
|
||||
- **Framework**: Node.js with Express 4.21
|
||||
- **Framework**: Node.js with Express 4.18.2
|
||||
- **API Design**: RESTful endpoints
|
||||
- **Authentication**:
|
||||
- JWT-based authentication for admin access
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue