init: first version of cca viewer

This commit is contained in:
JamesFlare1212
2025-05-12 23:55:28 -04:00
commit 2cba86eb45
24 changed files with 4781 additions and 0 deletions

100
src/components/ClubCard.tsx Normal file
View File

@@ -0,0 +1,100 @@
import React, { useState, useEffect, useCallback } from 'react';
import type { ClubBasic, ClubDetail } from '@types';
import { getClubDetails } from '@utils/apiService';
interface ClubCardProps {
club: ClubBasic;
initialDetails?: ClubDetail | null;
onDetailsLoaded?: (clubId: string, details: ClubDetail) => void;
onViewMore: (clubDetail: ClubDetail) => void; // Callback to open modal in parent
}
const ClubCard: React.FC<ClubCardProps> = ({ club, initialDetails = null, onDetailsLoaded, onViewMore }) => {
const [details, setDetails] = useState<ClubDetail | null>(initialDetails);
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(!initialDetails);
const [error, setError] = useState<string | null>(null);
const fetchDetailsIfNeeded = useCallback(async () => {
if (details || !club.id) return; // Already have details or no ID
setIsLoadingDetails(true);
setError(null);
try {
const fetchedDetails = await getClubDetails(club.id);
setDetails(fetchedDetails);
if (onDetailsLoaded) {
onDetailsLoaded(club.id, fetchedDetails);
}
} catch (err) {
console.error(`Failed to fetch details for club ${club.id}:`, err);
setError('Could not load some details.'); // Less critical error now
} finally {
setIsLoadingDetails(false);
}
}, [club.id, details, onDetailsLoaded]);
// Fetch details when component mounts if not already provided
useEffect(() => {
fetchDetailsIfNeeded();
}, [fetchDetailsIfNeeded]);
const handleViewMoreClick = () => {
if (details) {
onViewMore(details);
} else if (!isLoadingDetails) {
fetchDetailsIfNeeded().then(() => {
// Re-check details state after fetch attempt
if(details) onViewMore(details);
});
}
};
const renderDetailItem = (label: string, value: string | undefined | null) => {
if (isLoadingDetails && !details) return <p className="card-info-placeholder">{label}: Loading...</p>;
// Check specifically for the case where details ARE loaded but the value is missing/null/undefined
if (details && !value) return <p className="card-info-placeholder">{label}: N/A</p>;
// If details are not loaded yet, and we are not actively loading, show N/A or nothing? Let's stick to Loading... or the value
if (!details && !isLoadingDetails) return <p className="card-info-placeholder">{label}: N/A</p> // Or maybe return null
// If we have a value (implies details are loaded)
if (value) return <p><strong>{label}:</strong> {value}</p>;
// Fallback for the loading state (already handled above, but as safety)
return <p className="card-info-placeholder">{label}: Loading...</p>;
}
return (
<div className="card">
<div className="card-photo-container">
{/* === Updated img tag below === */}
<img
src={club.photo}
alt={club.name}
className="card-photo"
loading="lazy" // Defer loading until near viewport
decoding="async" // Hint to decode off main thread
fetchpriority="low" // Hint that it's not critical compared to other resources
/>
{/* === End of updated img tag === */}
</div>
<div className="card-content">
<h3>{club.name}</h3>
{error && <p className="error-message" style={{ fontSize: '0.8em', color: '#e74c3c' }}>{error}</p>}
{renderDetailItem("Category", details?.category)}
{renderDetailItem("Grades", details ? `G${details.grades.min} - G${details.grades.max}` : undefined)}
{renderDetailItem("Meets", details ? `${details.meeting.day}, ${details.meeting.startTime}-${details.meeting.endTime}` : undefined)}
{renderDetailItem("Location", details?.meeting.location.room)}
<button
onClick={handleViewMoreClick}
className="card-button"
disabled={isLoadingDetails && !details} // Disable if actively loading initial essential details
>
{isLoadingDetails && !details ? 'Loading Details...' : 'View Full Details'}
</button>
</div>
</div>
);
};
export default ClubCard;

View File

@@ -0,0 +1,94 @@
import React from 'react';
import type { ClubDetail } from '@types';
interface ClubDetailModalProps {
isOpen: boolean;
onClose: () => void;
clubDetail: ClubDetail | null;
}
const ClubDetailModal: React.FC<ClubDetailModalProps> = ({ isOpen, onClose, clubDetail }) => {
if (!isOpen || !clubDetail) {
return null;
}
// Helper to format semester cost
const formatSemesterCost = (cost: number | null): string => {
if (cost === null) {
return 'N/A';
}
if (cost === 0) {
return 'Free';
}
// Assuming the cost is a simple number, you might want to add currency formatting
// e.g., using Intl.NumberFormat if it's a monetary value with a specific currency.
// For now, just displaying the number.
return String(cost); // Or `cost.toFixed(2)` if it's meant to be currency-like
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close-button" onClick={onClose} aria-label="Close modal">&times;</button>
<h2>{clubDetail.name}</h2>
<div className="modal-scrollable-content">
<img src={clubDetail.photo} alt={clubDetail.name} className="modal-photo" />
<p><strong>ID:</strong> {clubDetail.id}</p>
<p><strong>Academic Year:</strong> {clubDetail.academicYear}</p>
<p><strong>Category:</strong> {clubDetail.category}</p>
<p><strong>Grades:</strong> G{clubDetail.grades.min} - G{clubDetail.grades.max}</p>
<p><strong>Schedule:</strong> {clubDetail.schedule}</p>
<h4>Meeting Information</h4>
<p><strong>Day:</strong> {clubDetail.meeting.day}</p>
<p><strong>Time:</strong> {clubDetail.meeting.startTime} - {clubDetail.meeting.endTime}</p>
<p><strong>Location:</strong> {clubDetail.meeting.location.room} ({clubDetail.meeting.location.block}, {clubDetail.meeting.location.site})</p>
<p><strong>Duration:</strong> {clubDetail.duration.startDate} to {clubDetail.duration.endDate} ({clubDetail.duration.isRecurringWeekly ? "Recurring Weekly" : "Fixed Duration"})</p>
<h4>Description</h4>
<p style={{ whiteSpace: 'pre-wrap' }}>{clubDetail.description}</p>
{/* Display Semester Cost if it's not null */}
{/* The API defines semesterCost as 'null' or a number */}
<p><strong>Semester Cost:</strong> {formatSemesterCost(clubDetail.semesterCost)}</p>
{/* Display Poor Weather Plan if it's not an empty string */}
{clubDetail.poorWeatherPlan && clubDetail.poorWeatherPlan.trim() !== '' && (
<>
<h4>Poor Weather Plan</h4>
<p style={{ whiteSpace: 'pre-wrap' }}>{clubDetail.poorWeatherPlan}</p>
</>
)}
{clubDetail.requirements && clubDetail.requirements.length > 0 && (
<>
<h4>Requirements</h4>
<ul>
{clubDetail.requirements.map((req, index) => <li key={`req-${index}`}>{req}</li>)}
</ul>
</>
)}
{clubDetail.materials && clubDetail.materials.length > 0 && (
<>
<h4>Materials Needed</h4>
<ul>
{clubDetail.materials.map((mat, index) => <li key={`mat-${index}`}>{mat}</li>)}
</ul>
</>
)}
<p><strong>Student Led:</strong> {clubDetail.isStudentLed ? 'Yes' : 'No'}</p>
{clubDetail.studentLeaders && clubDetail.studentLeaders.length > 0 && (
<p><strong>Student Leaders:</strong> {clubDetail.studentLeaders.join(', ')}</p>
)}
<p><strong>Staff:</strong> {clubDetail.staff.join(', ')}</p>
<p><small>Cache Status: {clubDetail.cache} (Last Checked: {new Date(clubDetail.lastCheck).toLocaleString()})</small></p>
</div>
</div>
</div>
);
};
export default ClubDetailModal;

173
src/components/ClubList.tsx Normal file
View File

@@ -0,0 +1,173 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import Fuse from 'fuse.js';
import type { ClubBasic, ClubDetail, CategoryCount, AcademicYearCount } from '@types';
import {
listAllClubs,
filterClubs,
getAvailableCategories,
getAvailableAcademicYears,
} from '@utils/apiService';
import { debounce } from '@utils/debounce';
import ClubCard from './ClubCard';
import SearchBar from './SearchBar';
import ClubDetailModal from './ClubDetailModal'; // Import the modal
const ClubList: React.FC = () => {
const [allClubsData, setAllClubsData] = useState<ClubBasic[]>([]);
const [displayedClubs, setDisplayedClubs] = useState<ClubBasic[]>([]);
const [clubDetailsCache, setClubDetailsCache] = useState<{ [id: string]: ClubDetail }>({});
const [searchTerm, setSearchTerm] = useState<string>('');
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedAcademicYear, setSelectedAcademicYear] = useState<string>('');
const [selectedGrade, setSelectedGrade] = useState<string>('');
const [availableCategories, setAvailableCategories] = useState<CategoryCount>({});
const [availableAcademicYears, setAvailableAcademicYears] = useState<AcademicYearCount>({});
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Modal State
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [selectedClubForModal, setSelectedClubForModal] = useState<ClubDetail | null>(null);
const fuse = useMemo(() => {
if (!allClubsData.length) return null;
return new Fuse(allClubsData, {
keys: ['name', 'id'],
threshold: 0.3,
});
}, [allClubsData]);
useEffect(() => {
const fetchFilterOptions = async () => {
try {
const [categories, academicYears] = await Promise.all([
getAvailableCategories(),
getAvailableAcademicYears(),
]);
setAvailableCategories(categories);
setAvailableAcademicYears(academicYears);
} catch (err) {
console.error('Failed to fetch filter options:', err);
}
};
fetchFilterOptions();
}, []);
const fetchFilteredClubsFromAPI = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const gradeNumber = selectedGrade ? parseInt(selectedGrade, 10) : undefined;
if (selectedGrade && (isNaN(gradeNumber) || gradeNumber < 1 || gradeNumber > 12)) {
setError("Invalid grade. Please enter a number between 1 and 12.");
setAllClubsData([]);
setDisplayedClubs([]);
setIsLoading(false);
return;
}
let clubs;
if (selectedCategory || selectedAcademicYear || (gradeNumber && gradeNumber >= 1 && gradeNumber <= 12)) {
clubs = await filterClubs({
category: selectedCategory || undefined,
academicYear: selectedAcademicYear || undefined,
grade: gradeNumber,
});
} else {
clubs = await listAllClubs();
}
setAllClubsData(clubs);
} catch (err) {
console.error('Failed to fetch clubs:', err);
setError('Could not load clubs. Please try refreshing.');
setAllClubsData([]);
} finally {
setIsLoading(false);
}
}, [selectedCategory, selectedAcademicYear, selectedGrade]);
useEffect(() => {
fetchFilteredClubsFromAPI();
}, [fetchFilteredClubsFromAPI]);
useEffect(() => {
if (!allClubsData.length && !searchTerm) {
setDisplayedClubs([]);
return;
}
if (!fuse) {
setDisplayedClubs(allClubsData);
return;
}
if (searchTerm.trim() === '') {
setDisplayedClubs(allClubsData);
} else {
const results = fuse.search(searchTerm);
setDisplayedClubs(results.map(result => result.item));
}
}, [searchTerm, allClubsData, fuse]);
const debouncedSetSearchTerm = useMemo(() => debounce(setSearchTerm, 300), []);
const handleDetailsLoadedForCache = useCallback((clubId: string, details: ClubDetail) => {
setClubDetailsCache(prevCache => ({ ...prevCache, [clubId]: details }));
}, []);
// Modal handler functions
const handleOpenModal = useCallback((clubDetail: ClubDetail) => {
setSelectedClubForModal(clubDetail);
setIsModalOpen(true);
}, []);
const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
setSelectedClubForModal(null);
}, []);
if (isLoading && !allClubsData.length && !Object.keys(availableCategories).length) return <p className="loading-message">Loading clubs and filters...</p>;
if (error) return <p className="error-message">{error}</p>;
return (
<div>
<SearchBar
onSearchTermChange={debouncedSetSearchTerm}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
availableCategories={availableCategories}
selectedAcademicYear={selectedAcademicYear}
onAcademicYearChange={setSelectedAcademicYear}
availableAcademicYears={availableAcademicYears}
selectedGrade={selectedGrade}
onGradeChange={setSelectedGrade}
/>
{isLoading && <p className="loading-message">Filtering clubs...</p>}
{!isLoading && displayedClubs.length === 0 && (
<p className="loading-message">No clubs match your criteria.</p>
)}
<div className="cards-grid">
{displayedClubs.map(club => (
<ClubCard
key={club.id}
club={club}
initialDetails={clubDetailsCache[club.id]} // Pass cached details if available
onDetailsLoaded={handleDetailsLoadedForCache}
onViewMore={handleOpenModal} // Pass the modal opener function
/>
))}
</div>
<ClubDetailModal
isOpen={isModalOpen}
onClose={handleCloseModal}
clubDetail={selectedClubForModal}
/>
</div>
);
};
export default ClubList;

View File

@@ -0,0 +1,6 @@
---
const currentYear = new Date().getFullYear();
---
<footer>
<p>&copy; {currentYear} DSAS CCA Information. API by James Flare.</p>
</footer>

View File

@@ -0,0 +1,10 @@
---
// No props needed for this simple header
---
<header>
<h1>DSAS CCA Viewer</h1>
<nav>
<a href="/">Clubs</a>
<a href="/staff">Staff</a>
</nav>
</header>

View File

@@ -0,0 +1,82 @@
import React, { useState } from 'react'; // Removed useEffect as it's not needed here anymore
import type { CategoryCount, AcademicYearCount } from '@types';
interface SearchBarProps {
// initialSearchTerm?: string; // Could be used if you want to pre-fill from URL params etc.
onSearchTermChange: (term: string) => void; // This is the debounced function from parent
selectedCategory: string;
onCategoryChange: (category: string) => void;
availableCategories: CategoryCount;
selectedAcademicYear: string;
onAcademicYearChange: (year: string) => void;
availableAcademicYears: AcademicYearCount;
selectedGrade: string;
onGradeChange: (grade: string) => void;
}
const SearchBar: React.FC<SearchBarProps> = ({
onSearchTermChange, // This is the debounced function from ClubList
selectedCategory,
onCategoryChange,
availableCategories,
selectedAcademicYear,
onAcademicYearChange,
availableAcademicYears,
selectedGrade,
onGradeChange,
}) => {
const [inputValue, setInputValue] = useState<string>(''); // Local state for immediate input update
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue); // Update local state immediately for responsive input field
onSearchTermChange(newValue); // Call the debounced function passed from the parent
};
return (
<div className="search-bar-container">
<input
type="text"
placeholder="Search clubs by name or ID..."
value={inputValue} // Bind to local state
onChange={handleInputChange}
aria-label="Search clubs"
/>
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value)}
aria-label="Filter by category"
>
<option value="">All Categories</option>
{Object.entries(availableCategories).map(([category, count]) => (
<option key={category} value={category}>
{category} ({count})
</option>
))}
</select>
<select
value={selectedAcademicYear}
onChange={(e) => onAcademicYearChange(e.target.value)}
aria-label="Filter by academic year"
>
<option value="">All Academic Years</option>
{Object.entries(availableAcademicYears).map(([year, count]) => (
<option key={year} value={year}>
{year} ({count})
</option>
))}
</select>
<input
type="number"
placeholder="Grade (1-12)"
value={selectedGrade} // Grade filter updates directly, not usually typed fast
onChange={(e) => onGradeChange(e.target.value)}
min="1"
max="12"
aria-label="Filter by grade"
/>
</div>
);
};
export default SearchBar;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import type { StaffMember } from '@types';
interface StaffCardProps {
staff: StaffMember;
}
const StaffCard: React.FC<StaffCardProps> = ({ staff }) => {
return (
<div className="card staff-card"> {/* Re-use card for base styling, add specific class */}
<h3>{staff.name}</h3>
<p><strong>ID:</strong> {staff.id}</p>
</div>
);
};
export default StaffCard;

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect, useMemo } from 'react';
import Fuse from 'fuse.js';
import type { StaffMember } from '@types';
import { getAllStaff } from '@utils/apiService';
import { debounce } from '@utils/debounce';
import StaffCard from './StaffCard';
const StaffList: React.FC = () => {
const [allStaff, setAllStaff] = useState<StaffMember[]>([]);
const [displayedStaff, setDisplayedStaff] = useState<StaffMember[]>([]);
const [inputValue, setInputValue] = useState<string>(''); // For immediate input responsiveness
const [actualSearchTerm, setActualSearchTerm] = useState<string>(''); // For debounced search logic
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStaff = async () => {
setIsLoading(true);
setError(null);
try {
const staffData = await getAllStaff();
setAllStaff(staffData);
// setDisplayedStaff(staffData); // Moved to the search effect
} catch (err) {
console.error('Failed to fetch staff:', err);
setError('Could not load staff members. Please try refreshing.');
} finally {
setIsLoading(false);
}
};
fetchStaff();
}, []);
const fuse = useMemo(() => {
if (!allStaff.length) return null;
return new Fuse(allStaff, {
keys: ['name', 'id'],
threshold: 0.3,
});
}, [allStaff]);
// Debounced function to update the actualSearchTerm that triggers Fuse.js
const debouncedSetActualSearchTerm = useMemo(
() => debounce(setActualSearchTerm, 300),
[] // No dependencies, debounce function is stable
);
// Effect to apply Fuse.js search based on actualSearchTerm
useEffect(() => {
if (isLoading) return; // Don't filter if still loading initial data
if (!fuse) { // If fuse is not ready (e.g. allStaff is empty)
setDisplayedStaff(allStaff); // Show all staff (which would be empty if allStaff is empty)
return;
}
if (actualSearchTerm.trim() === '') {
setDisplayedStaff(allStaff);
} else {
const results = fuse.search(actualSearchTerm);
setDisplayedStaff(results.map(result => result.item));
}
}, [actualSearchTerm, allStaff, fuse, isLoading]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue); // Update input field immediately
debouncedSetActualSearchTerm(newValue); // Trigger debounced search logic
};
if (isLoading && !allStaff.length) return <p className="loading-message">Loading staff members...</p>;
if (error) return <p className="error-message">{error}</p>;
return (
<div>
<div className="search-bar-container" style={{ marginBottom: '2rem' }}>
<input
type="text"
placeholder="Search staff by name or ID..."
value={inputValue} // Use local inputValue for responsiveness
onChange={handleInputChange}
aria-label="Search staff"
style={{width: '100%'}}
/>
</div>
{isLoading && <p className="loading-message">Processing...</p>}
{!isLoading && displayedStaff.length === 0 && actualSearchTerm && (
<p className="loading-message">No staff members match "{actualSearchTerm}".</p>
)}
{!isLoading && displayedStaff.length === 0 && !actualSearchTerm && allStaff.length > 0 && (
<p className="loading-message">No staff members found (Type to search).</p>
)}
{!isLoading && displayedStaff.length === 0 && !actualSearchTerm && allStaff.length === 0 && !error && (
<p className="loading-message">No staff data available.</p>
)}
<div className="cards-grid">
{displayedStaff.map(staffMember => (
<StaffCard key={staffMember.id} staff={staffMember} />
))}
</div>
</div>
);
};
export default StaffList;

31
src/layouts/Layout.astro Normal file
View File

@@ -0,0 +1,31 @@
---
import Header from '@components/Header.astro';
import Footer from '@components/Footer.astro';
import '@styles/global.css';
interface Props {
title: string;
description?: string;
}
const { title, description = "DSAS Co-Curricular Activities" } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>{title} | DSAS CCA</title>
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
</html>

8
src/pages/index.astro Normal file
View File

@@ -0,0 +1,8 @@
---
import Layout from '@layouts/Layout.astro';
import ClubList from '@components/ClubList.tsx';
---
<Layout title="DSAS Clubs">
<ClubList client:load />
</Layout>

8
src/pages/staff.astro Normal file
View File

@@ -0,0 +1,8 @@
---
import Layout from '@layouts/Layout.astro';
import StaffList from '@components/StaffList.tsx';
---
<Layout title="DSAS Staff">
<StaffList client:load />
</Layout>

297
src/styles/global.css Normal file
View File

@@ -0,0 +1,297 @@
/* Basic Reset & Defaults */
body {
font-family: sans-serif;
margin: 0;
background-color: #f4f7f6;
color: #333;
line-height: 1.6;
}
header {
background-color: #2c3e50;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
margin: 0;
font-size: 1.8rem;
}
header nav a {
color: white;
text-decoration: none;
margin-left: 1.5rem;
font-size: 1.1rem;
transition: color 0.2s ease-in-out;
}
header nav a:hover {
color: #1abc9c;
}
main {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
footer {
text-align: center;
padding: 1.5rem;
background-color: #34495e;
color: #ecf0f1;
margin-top: 2rem;
}
/* Search Bar Styles */
.search-bar-container {
background-color: #ffffff;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
display: flex;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
gap: 1rem;
align-items: center;
}
.search-bar-container input[type="text"],
.search-bar-container input[type="number"],
.search-bar-container select {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
flex-grow: 1; /* Allow text input to take more space */
min-width: 150px; /* Minimum width for inputs/selects */
}
.search-bar-container input[type="text"] {
flex-basis: 300px; /* Give more base width to search text */
}
/* Grid for Cards */
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
/* Card Styles */
.card {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
overflow: hidden;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
display: flex;
flex-direction: column;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 12px rgba(0,0,0,0.15);
}
.card-photo-container {
width: 100%;
/* aspect-ratio: 16 / 9; /* Example: Adjust ratio (width / height) as needed, e.g., 4 / 3, 3 / 2, 1 / 1 */
/* Common CCA photo ratios might be closer to 4:3 or 16:9. Let's try 16:9 */
aspect-ratio: 16 / 9;
background-color: #e0e0e0; /* Placeholder background */
overflow: hidden; /* Ensures image stays within bounds */
display: block; /* Or flex if alignment inside is needed, but block is simpler */
}
.card-photo {
display: block; /* Removes potential extra space sometimes added below images */
width: 100%;
height: 100%; /* Make image fill the container */
object-fit: cover; /* Cover the container, cropping if necessary, maintaining aspect ratio */
}
.card-content {
padding: 1rem;
flex-grow: 1; /* Allows content to fill remaining space */
display: flex;
flex-direction: column;
}
.card-content h3 {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 1.4rem;
color: #333;
}
.card-info-placeholder {
font-style: italic;
color: #888;
font-size: 0.9rem;
min-height: 1.2em; /* Reserve space to reduce layout shift */
}
.card-details-section {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #eee;
}
.card-details-section p {
margin: 0.3rem 0;
font-size: 0.9rem;
}
.card-details-section strong {
color: #555;
}
.card-button {
background-color: #1abc9c;
color: white;
border: none;
padding: 0.6rem 1rem;
border-radius: 4px;
cursor: pointer;
text-align: center;
font-size: 0.9rem;
margin-top: 1rem; /* Pushes button to bottom if card content is short */
transition: background-color 0.2s ease-in-out;
}
.card-button:hover {
background-color: #16a085;
}
/* Loading and Error Messages */
.loading-message, .error-message {
text-align: center;
font-size: 1.2rem;
padding: 2rem;
color: #555;
}
.error-message {
color: #e74c3c;
}
/* Staff Card Specifics */
.staff-card {
padding: 1.5rem;
}
.staff-card h3 {
font-size: 1.2rem;
}
.staff-card p {
font-size: 1rem;
color: #555;
}
/* Responsive adjustments */
@media (max-width: 768px) {
header {
flex-direction: column;
align-items: flex-start;
}
header nav {
margin-top: 0.5rem;
}
header nav a {
margin-left: 0;
margin-right: 1rem;
}
.search-bar-container {
flex-direction: column;
align-items: stretch;
}
.search-bar-container input[type="text"],
.search-bar-container input[type="number"],
.search-bar-container select {
width: 100%;
}
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000; /* Ensure it's on top */
padding: 1rem;
}
.modal-content {
background-color: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
width: 90%;
max-width: 700px; /* Max width of modal */
max-height: 90vh; /* Max height of modal */
position: relative;
display: flex;
flex-direction: column;
}
.modal-scrollable-content {
overflow-y: auto; /* Makes content scrollable if it exceeds max-height */
flex-grow: 1; /* Allows this section to take available space and scroll */
padding-right: 1rem; /* For scrollbar spacing */
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.modal-content h4 {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
color: #1abc9c;
}
.modal-content p, .modal-content li {
margin-bottom: 0.5rem;
line-height: 1.6;
}
.modal-content ul {
padding-left: 20px;
}
.modal-photo {
width: 100%;
max-height: 300px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 1rem;
}
.modal-close-button {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 2rem;
font-weight: bold;
color: #888;
cursor: pointer;
line-height: 1;
}
.modal-close-button:hover {
color: #333;
}

77
src/types/index.ts Normal file
View File

@@ -0,0 +1,77 @@
export interface ClubBasic {
id: string; // API gives numbers as keys, but we'll use them as strings
name: string;
photo: string;
}
export interface ClubMeeting {
day: string;
endTime: string;
location: {
block: string;
room: string;
site: string;
};
startTime: string;
}
export interface ClubDuration {
endDate: string;
isRecurringWeekly: boolean;
startDate: string;
}
export interface ClubGrades {
max: string; // API gives strings
min: string; // API gives strings
}
export interface ClubDetail {
academicYear: string;
category: string;
description: string;
duration: ClubDuration;
grades: ClubGrades;
id: string;
isPreSignup: boolean;
isStudentLed: boolean;
materials: string[];
meeting: ClubMeeting;
name: string;
photo: string;
poorWeatherPlan: string;
requirements: string[];
schedule: string;
semesterCost: number | null;
staff: string[];
staffForReports: string[];
studentLeaders: string[];
lastCheck: string;
cache: "HIT" | "MISS" | "ERROR";
}
export interface CategoryCount {
[categoryName: string]: number;
}
export interface AcademicYearCount {
[academicYear: string]: number;
}
export interface StaffMember {
id: string;
name: string;
}
// For the API response format of /v1/activity/list
export interface ApiClubListResponse {
[id: string]: {
name: string;
photo: string;
};
}
// For the API response format of /v1/staffs
export interface ApiStaffListResponse {
[id: string]: string;
}

64
src/utils/apiService.ts Normal file
View File

@@ -0,0 +1,64 @@
import type {
ApiClubListResponse,
ClubDetail,
CategoryCount,
AcademicYearCount,
ApiStaffListResponse,
ClubBasic,
StaffMember
} from '@types';
const BASE_URL = 'https://dsas-cca.jamesflare.com/v1';
async function fetchWithErrorHandling<T>(url: string): Promise<T> {
try {
const response = await fetch(url);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to parse error response" }));
throw new Error(
`API Error: ${response.status} ${response.statusText}. ${errorData?.error || ''}`
);
}
return response.json() as Promise<T>;
} catch (error) {
console.error(`Failed to fetch from ${url}:`, error);
throw error; // Re-throw to be handled by the caller
}
}
export async function listAllClubs(): Promise<ClubBasic[]> {
const data = await fetchWithErrorHandling<ApiClubListResponse>(`${BASE_URL}/activity/list`);
return Object.entries(data).map(([id, club]) => ({ id, ...club }));
}
export async function filterClubs(params: {
category?: string;
academicYear?: string;
grade?: number;
}): Promise<ClubBasic[]> {
const queryParams = new URLSearchParams();
if (params.category) queryParams.append('category', params.category);
if (params.academicYear) queryParams.append('academicYear', params.academicYear);
if (params.grade !== undefined) queryParams.append('grade', String(params.grade));
const url = `${BASE_URL}/activity/list?${queryParams.toString()}`;
const data = await fetchWithErrorHandling<ApiClubListResponse>(url);
return Object.entries(data).map(([id, club]) => ({ id, ...club }));
}
export async function getClubDetails(activityId: string): Promise<ClubDetail> {
return fetchWithErrorHandling<ClubDetail>(`${BASE_URL}/activity/${activityId}`);
}
export async function getAvailableCategories(): Promise<CategoryCount> {
return fetchWithErrorHandling<CategoryCount>(`${BASE_URL}/activity/category`);
}
export async function getAvailableAcademicYears(): Promise<AcademicYearCount> {
return fetchWithErrorHandling<AcademicYearCount>(`${BASE_URL}/activity/academicYear`);
}
export async function getAllStaff(): Promise<StaffMember[]> {
const data = await fetchWithErrorHandling<ApiStaffListResponse>(`${BASE_URL}/staffs`);
return Object.entries(data).map(([id, name]) => ({ id, name }));
}

16
src/utils/debounce.ts Normal file
View File

@@ -0,0 +1,16 @@
export function debounce<T extends (...args: any[]) => void>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func.apply(this, args);
timeoutId = null;
}, delay);
};
}