init: first version of cca viewer
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
README.md
Normal file
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Astro Starter Kit: Minimal
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm create astro@latest -- --template minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
||||||
|
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
|
||||||
|
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
|
|
||||||
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `pnpm install` | Installs dependencies |
|
||||||
|
| `pnpm dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `pnpm build` | Build your production site to `./dist/` |
|
||||||
|
| `pnpm preview` | Preview your build locally, before deploying |
|
||||||
|
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `pnpm astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||||
9
astro.config.mjs
Normal file
9
astro.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
import react from '@astrojs/react';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
integrations: [react()]
|
||||||
|
});
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "dsas-cca-frontend",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/react": "^4.2.7",
|
||||||
|
"astro": "^5.7.12",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.4",
|
||||||
|
"@types/react-dom": "^19.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
3545
pnpm-lock.yaml
generated
Normal file
3545
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
public/favicon.svg
Normal file
9
public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||||
|
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||||
|
<style>
|
||||||
|
path { fill: #000; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { fill: #FFF; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 749 B |
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@components/*": ["src/components/*"],
|
||||||
|
"@layouts/*": ["src/layouts/*"],
|
||||||
|
"@pages/*": ["src/pages/*"],
|
||||||
|
"@styles/*": ["src/styles/*"],
|
||||||
|
"@utils/*": ["src/utils/*"],
|
||||||
|
"@types/*": ["src/types/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user