init: first version of cca viewer
This commit is contained in:
100
src/components/ClubCard.tsx
Normal file
100
src/components/ClubCard.tsx
Normal 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;
|
||||
94
src/components/ClubDetailModal.tsx
Normal file
94
src/components/ClubDetailModal.tsx
Normal 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">×</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
173
src/components/ClubList.tsx
Normal 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;
|
||||
6
src/components/Footer.astro
Normal file
6
src/components/Footer.astro
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
<footer>
|
||||
<p>© {currentYear} DSAS CCA Information. API by James Flare.</p>
|
||||
</footer>
|
||||
10
src/components/Header.astro
Normal file
10
src/components/Header.astro
Normal 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>
|
||||
82
src/components/SearchBar.tsx
Normal file
82
src/components/SearchBar.tsx
Normal 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;
|
||||
17
src/components/StaffCard.tsx
Normal file
17
src/components/StaffCard.tsx
Normal 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;
|
||||
109
src/components/StaffList.tsx
Normal file
109
src/components/StaffList.tsx
Normal 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
31
src/layouts/Layout.astro
Normal 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
8
src/pages/index.astro
Normal 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
8
src/pages/staff.astro
Normal 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
297
src/styles/global.css
Normal 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
77
src/types/index.ts
Normal 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
64
src/utils/apiService.ts
Normal 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
16
src/utils/debounce.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user