diff --git a/.github/workflows/sync-firebase-images.yml b/.github/workflows/sync-firebase-images.yml new file mode 100644 index 00000000..a462666c --- /dev/null +++ b/.github/workflows/sync-firebase-images.yml @@ -0,0 +1,102 @@ +name: Sync Firebase Officer Images + +on: + pull_request: + workflow_dispatch: + inputs: + dry_run: + description: 'Perform a dry run without creating PR' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + storage_bucket: + description: 'Firebase Storage bucket name (leave empty for default)' + required: false + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + sync-images: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref || 'main' }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Install project dependencies + run: npm ci + + - name: Install sync script dependencies + run: npm install --no-save firebase-admin @opentelemetry/api + + - name: Create Firebase config + run: | + cat <<'EOF' > firebase-config.json + ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }} + EOF + + - name: Run sync script + env: + FIREBASE_CONFIG_PATH: firebase-config.json + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + FIREBASE_STORAGE_BUCKET: ${{ github.event.inputs.storage_bucket || secrets.FIREBASE_STORAGE_BUCKET || '' }} + run: node scripts/sync-firebase-images.js + + - name: Validate generated TypeScript + run: npx tsc --noEmit + + - name: Create Pull Request + if: (github.event.inputs.dry_run || 'false') == 'false' + id: create-pr + uses: peter-evans/create-pull-request@v5 + with: + commit-message: 'chore: sync officer images from firebase' + title: 'chore: sync officer images from firebase' + body: | + This PR automatically syncs officer images from Firebase Storage. + + **Changes:** + - Cleared old images from `public/assets/officer/` + - Downloaded latest officer images from Firebase Storage + - Exported Firestore officers grouped by division to `config/officers.config.ts` + + The sync script: + 1. Removes all existing images + 2. Fetches current officer data from Firestore + 3. Downloads images from Firebase Storage + 4. Renames files based on officer names (firstName-lastName) + 5. Exports officers from `officers` collection as typed division-based arrays + + Generated by: [Sync Firebase Officer Images](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + branch: sync/firebase-officer-images + base: ${{ github.event.pull_request.head.ref || 'main' }} + delete-branch: true + labels: automated + add-paths: | + public/assets/officer/** + config/officers.config.ts + + - name: Summary + if: always() + run: | + echo "## Sync Summary" >> $GITHUB_STEP_SUMMARY + echo "**Status:** Sync completed" >> $GITHUB_STEP_SUMMARY + if [ "${{ github.event.inputs.dry_run || 'false' }}" == "true" ]; then + echo "Mode: dry run" >> $GITHUB_STEP_SUMMARY + elif [ -n "${{ steps.create-pr.outputs.pull-request-number }}" ]; then + echo "PR: #${{ steps.create-pr.outputs.pull-request-number }}" >> $GITHUB_STEP_SUMMARY + else + echo "No PR created" >> $GITHUB_STEP_SUMMARY + fi \ No newline at end of file diff --git a/config/officers.config.ts b/config/officers.config.ts index 6b9ec6b3..961b6b6b 100644 --- a/config/officers.config.ts +++ b/config/officers.config.ts @@ -7,20 +7,29 @@ import { industryOfficers } from './industry.config'; import { mediaOfficers } from './media.config'; import { projectsOfficers } from './projects.config'; import { researchOfficers } from './research.config'; + export type Officer = { name: string; position: string; image: string; + level?: number; + socialLinks?: Record; }; -const defaultOfficer: Officer = { - name: 'Saksham Sangrula', - position: 'President', - image: '/assets/officer/OfficerImage.png', +export type ExportedOfficer = { + firstName: string; + lastName: string; + photo: { + url: string; + }; + socialLinks: Record; + level: number; + title: string; }; type Divisions = | 'advisor' + | 'executive' | 'board' | 'media' | 'research' @@ -32,11 +41,14 @@ type Divisions = | 'industry'; export const divisionOfficerMap: Record = { - advisor: [{ - image: '/assets/officer/JohnCole.png', - name: 'John Cole', - position: 'ACM Faculty Advisor', - }], + advisor: [ + { + image: '/assets/officer/JohnCole.png', + name: 'John Cole', + position: 'ACM Faculty Sponsor', + }, + ], + executive: boardOfficers, board: boardOfficers, community: communityOfficers, development: developmentOfficers, @@ -47,3 +59,49 @@ export const divisionOfficerMap: Record = { research: researchOfficers, projects: projectsOfficers, }; + +function splitName(name: string) { + const trimmedName = name.trim(); + const nameParts = trimmedName.split(/\s+/); + + if (nameParts.length === 0) { + return { firstName: '', lastName: '' }; + } + + if (nameParts.length === 1) { + return { firstName: nameParts[0], lastName: '' }; + } + + return { + firstName: nameParts[0], + lastName: nameParts.slice(1).join(' '), + }; +} + +function toExportedOfficer(officer: Officer): ExportedOfficer { + const { firstName, lastName } = splitName(officer.name); + + return { + firstName, + lastName, + photo: { + url: officer.image, + }, + socialLinks: {}, + level: 0, + title: officer.position, + }; +} + +export const officersByDivision: Record = { + Advisor: divisionOfficerMap.advisor.map(toExportedOfficer), + Executive: divisionOfficerMap.executive.map(toExportedOfficer), + Media: divisionOfficerMap.media.map(toExportedOfficer), + Research: divisionOfficerMap.research.map(toExportedOfficer), + Development: divisionOfficerMap.development.map(toExportedOfficer), + Projects: divisionOfficerMap.projects.map(toExportedOfficer), + Education: divisionOfficerMap.education.map(toExportedOfficer), + Community: divisionOfficerMap.community.map(toExportedOfficer), + HackUTD: divisionOfficerMap.hackutd.map(toExportedOfficer), + Industry: divisionOfficerMap.industry.map(toExportedOfficer), +}; \ No newline at end of file diff --git a/public/assets/officer/JohnCole.png b/public/assets/JohnCole.png similarity index 100% rename from public/assets/officer/JohnCole.png rename to public/assets/JohnCole.png diff --git a/public/assets/officer/OfficerImage.png b/public/assets/OfficerImage.png similarity index 100% rename from public/assets/officer/OfficerImage.png rename to public/assets/OfficerImage.png diff --git a/public/assets/officer/officer-bg.png b/public/assets/officer-bg.png similarity index 100% rename from public/assets/officer/officer-bg.png rename to public/assets/officer-bg.png diff --git a/public/assets/officer-peechi.png b/public/assets/officer-peechi.png new file mode 100644 index 00000000..010b676d Binary files /dev/null and b/public/assets/officer-peechi.png differ diff --git a/scripts/sync-firebase-images.js b/scripts/sync-firebase-images.js new file mode 100644 index 00000000..003f519f --- /dev/null +++ b/scripts/sync-firebase-images.js @@ -0,0 +1,650 @@ +const admin = require('firebase-admin'); +const fs = require('fs'); +const path = require('path'); + +const firebaseConfigPath = process.env.FIREBASE_CONFIG_PATH || 'firebase-creds.json'; +const dryRun = process.env.DRY_RUN === 'true'; +const outputDir = path.join(process.cwd(), 'public', 'assets', 'officer'); +const officersExportPath = path.join( + process.cwd(), + 'config', + 'officers.config.ts' +); + +const layoutDivisions = [ + 'advisor', + 'executive', + 'media', + 'research', + 'development', + 'projects', + 'education', + 'community', + 'hackutd', + 'industry', + 'finance', + 'board', +]; + +const permanentAdvisorOfficer = { + image: '/assets/JohnCole.png', + name: 'John Cole', + position: 'ACM Faculty Advisor', +}; + +// Initialize Firebase Admin SDK +const serviceAccount = JSON.parse( + fs.readFileSync(firebaseConfigPath, 'utf8') +); + +// Determine storage bucket +let storageBucket = process.env.FIREBASE_STORAGE_BUCKET; +if (!storageBucket) { + // Try common Firebase Storage bucket naming patterns + const projectId = serviceAccount.project_id; + // Newer Firebase projects use .firebasestorage.app + storageBucket = `${projectId}.firebasestorage.app`; + console.log(`ℹ️ No FIREBASE_STORAGE_BUCKET specified, trying: ${storageBucket}`); +} + +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), + storageBucket: storageBucket, +}); + +const bucket = admin.storage().bucket(); + +function toTitleCase(value) { + return value + .split(/[_\s-]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function normalizeDivision(division) { + if (typeof division !== 'string') { + return null; + } + + return division.trim().toLowerCase(); +} + +function getDivisionVarName(division) { + if (division === 'hackutd') { + return 'hackOfficers'; + } + + return `${division}Officers`; +} + +function escapeSingleQuotes(value) { + return String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +function extractRolesFromOfficer(officerData) { + const roles = []; + + if (!Array.isArray(officerData?.roles)) { + return roles; + } + + for (const role of officerData.roles) { + if (typeof role !== 'object' || role === null) { + continue; + } + + // Only include roles where endDate is null (current roles) + if (role.endDate !== null && role.endDate !== undefined) { + continue; + } + + const division = normalizeDivision(role.division); + if (!division) { + continue; + } + + const title = typeof role.title === 'string' ? role.title.trim() : ''; + const level = typeof role.level === 'number' ? role.level : 0; + + if (title) { + roles.push({ division, title, level }); + } + } + + return roles; +} + +function getBoardEligibleRole(roles) { + const boardRoles = roles.filter((role) => role.level >= 2); + if (boardRoles.length === 0) { + return null; + } + + return boardRoles.reduce((best, current) => + current.level > best.level ? current : best + ); +} + +function getOfficerUidFromData(data) { + const uidCandidates = [data?.uid, data?.id, data?.userId]; + + for (const uid of uidCandidates) { + if (typeof uid === 'string' && uid.trim() !== '') { + return uid; + } + } + + return null; +} + +function looksLikeOfficerRecord(record) { + return ( + record && + typeof record === 'object' && + !Array.isArray(record) && + typeof record.firstName === 'string' && + typeof record.lastName === 'string' + ); +} + +function extractOfficerEntries(snapshot) { + const entries = []; + + for (const doc of snapshot.docs) { + const data = doc.data(); + + if (looksLikeOfficerRecord(data)) { + entries.push({ + uid: doc.id, + data, + forcedDivision: null, + }); + } + + for (const [fieldKey, fieldValue] of Object.entries(data)) { + if (!Array.isArray(fieldValue)) { + continue; + } + + const division = normalizeDivision(fieldKey); + if (!division) { + continue; + } + + for (let index = 0; index < fieldValue.length; index += 1) { + const officerRecord = fieldValue[index]; + if (!looksLikeOfficerRecord(officerRecord)) { + continue; + } + + const embeddedUid = getOfficerUidFromData(officerRecord); + const syntheticUid = `${doc.id}:${division}:${index}`; + + entries.push({ + uid: embeddedUid || syntheticUid, + data: officerRecord, + forcedDivision: division, + }); + } + } + } + + return entries; +} + +function getSocialLinks(data) { + if ( + data?.socialLinks && + typeof data.socialLinks === 'object' && + !Array.isArray(data.socialLinks) + ) { + const socialLinks = {}; + + for (const [key, value] of Object.entries(data.socialLinks)) { + if (typeof value === 'string' && value.trim() !== '') { + socialLinks[key] = value; + } + } + + return socialLinks; + } + + const fallbackSocialLinks = {}; + const keys = [ + 'github', + 'linkedin', + 'instagram', + 'email', + ]; + + for (const key of keys) { + if (typeof data?.[key] === 'string' && data[key].trim() !== '') { + fallbackSocialLinks[key] = data[key]; + } + } + + return fallbackSocialLinks; +} + +function getPositionForDivision(data, division) { + const maps = [data?.positions, data?.positionByDivision, data?.roles, data?.roleByDivision]; + + for (const mapCandidate of maps) { + if ( + mapCandidate && + typeof mapCandidate === 'object' && + !Array.isArray(mapCandidate) + ) { + const directMatch = mapCandidate[division]; + if (typeof directMatch === 'string' && directMatch.trim() !== '') { + return directMatch; + } + + const altMatch = Object.entries(mapCandidate).find( + ([key, value]) => normalizeDivision(key) === division && typeof value === 'string' && value.trim() !== '' + ); + + if (altMatch) { + return altMatch[1]; + } + } + } + + if (typeof data?.position === 'string' && data.position.trim() !== '') { + return data.position; + } + + if (typeof data?.title === 'string' && data.title.trim() !== '') { + return data.title; + } + + if (typeof data?.role === 'string' && data.role.trim() !== '') { + return data.role; + } + + const divisionTitle = toTitleCase(division); + return division === 'executive' ? 'Executive Officer' : `${divisionTitle} Officer`; +} + +function getOfficerNameFromData(data) { + const firstName = data?.firstName; + const lastName = data?.lastName; + + if (!firstName || !lastName) { + return null; + } + + return `${firstName} ${lastName}`; +} + +function getOfficerSlugFromData(data) { + const firstName = data?.firstName; + const lastName = data?.lastName; + + if (!firstName || !lastName) { + return null; + } + + return `${firstName}-${lastName}`; +} + +function getExtensionFromContentType(contentType) { + if (typeof contentType !== 'string') { + return null; + } + + const normalizedType = contentType.toLowerCase(); + const extensionByType = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'image/gif': '.gif', + 'image/heic': '.heic', + 'image/heif': '.heif', + 'image/avif': '.avif', + }; + + return extensionByType[normalizedType] || null; +} + +async function resolveFileExtension(file, fileName) { + const extensionFromName = path.extname(fileName); + if (extensionFromName) { + return extensionFromName; + } + + const extensionFromCachedMetadata = getExtensionFromContentType( + file?.metadata?.contentType + ); + if (extensionFromCachedMetadata) { + return extensionFromCachedMetadata; + } + + try { + const [metadata] = await file.getMetadata(); + const extensionFromMetadata = getExtensionFromContentType(metadata?.contentType); + if (extensionFromMetadata) { + return extensionFromMetadata; + } + } catch { + console.warn( + `⚠️ Could not read metadata for ${file.name}; defaulting extension to .jpg` + ); + } + + return '.jpg'; +} + +function getImagePathForEntry(entry, imagePathByUid) { + if (imagePathByUid[entry.uid]) { + return imagePathByUid[entry.uid]; + } + + const firstName = entry.data?.firstName; + const lastName = entry.data?.lastName; + const photoUrl = entry.data?.photo?.url; + + // If photo URL is missing, use default fallback image + if (!photoUrl || typeof photoUrl !== 'string' || photoUrl.trim() === '') { + return '/assets/OfficerImage.png'; + } + + if ( + typeof firstName === 'string' && + firstName.trim() !== '' && + typeof lastName === 'string' && + lastName.trim() !== '' + ) { + let extension = '.jpg'; + + const withoutQuery = photoUrl.split('?')[0]; + const parsedExtension = path.extname(withoutQuery); + if (parsedExtension) { + extension = parsedExtension; + } + + const normalizedFirstName = firstName.trim().replace(/\s+/g, '_'); + const normalizedLastName = lastName.trim().replace(/\s+/g, '_'); + return `/assets/officer/${normalizedFirstName}_${normalizedLastName}${extension}`; + } + + return '/assets/OfficerImage.png'; +} + +function buildOfficerConfigSource(divisionsMap) { + const source = []; + source.push('export type Officer = {'); + source.push(' name: string;'); + source.push(' position: string;'); + source.push(' image: string;'); + source.push(' level?: number;'); + source.push(' socialLinks?: Record;'); + source.push('};'); + source.push(''); + + for (const division of layoutDivisions) { + const varName = getDivisionVarName(division); + const officers = divisionsMap[division] || []; + + source.push(`export const ${varName}: Officer[] = [`); + + for (const officer of officers) { + source.push(' {'); + source.push(` image: '${escapeSingleQuotes(officer.image)}',`); + source.push(` name: '${escapeSingleQuotes(officer.name)}',`); + source.push(` position: '${escapeSingleQuotes(officer.position)}',`); + + if (typeof officer.level === 'number') { + source.push(` level: ${officer.level},`); + } + + if (officer.socialLinks && Object.keys(officer.socialLinks).length > 0) { + source.push(' socialLinks: {'); + for (const [key, value] of Object.entries(officer.socialLinks)) { + source.push(` '${escapeSingleQuotes(key)}': '${escapeSingleQuotes(value)}',`); + } + source.push(' },'); + } + + source.push(' },'); + } + + source.push('];'); + source.push(''); + } + + source.push('type Divisions ='); + for (const division of layoutDivisions) { + source.push(` | '${division}'`); + } + source.push(';'); + source.push(''); + source.push('export const divisionOfficerMap: Record = {'); + + for (const division of layoutDivisions) { + source.push(` ${division}: ${getDivisionVarName(division)},`); + } + + source.push('};'); + source.push(''); + return source.join('\n'); +} + +async function exportOfficersByDivision(officerEntries, imagePathByUid) { + const officersByDivision = {}; + + for (const division of layoutDivisions) { + officersByDivision[division] = []; + } + + for (const entry of officerEntries) { + const uid = entry.uid; + const data = entry.data; + const officerName = getOfficerNameFromData(data); + if (!officerName) { + console.warn(`⚠️ Skipping officer ${uid} due to missing firstName/lastName`); + continue; + } + + const roles = extractRolesFromOfficer(data); + + if (roles.length === 0) { + console.warn(`⚠️ Officer ${uid} has no roles with valid divisions`); + continue; + } + + for (const role of roles) { + const division = role.division; + if (!officersByDivision[division]) { + continue; + } + + officersByDivision[division].push({ + image: getImagePathForEntry(entry, imagePathByUid), + name: officerName, + position: role.title, + level: role.level, + socialLinks: getSocialLinks(data), + }); + } + } + + officersByDivision.advisor = [permanentAdvisorOfficer]; + + // Populate board with officers who have level 2-3 roles + for (const entry of officerEntries) { + const data = entry.data; + const officerName = getOfficerNameFromData(data); + if (!officerName) { + continue; + } + + const roles = extractRolesFromOfficer(data); + const boardRole = getBoardEligibleRole(roles); + + if (boardRole) { + officersByDivision.board.push({ + image: getImagePathForEntry(entry, imagePathByUid), + name: officerName, + position: boardRole.title, + level: boardRole.level, + socialLinks: getSocialLinks(data), + }); + } + } + + for (const division of layoutDivisions) { + officersByDivision[division].sort((a, b) => { + const levelA = typeof a.level === 'number' ? a.level : 0; + const levelB = typeof b.level === 'number' ? b.level : 0; + + if (levelA !== levelB) { + return levelB - levelA; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + } + + const source = buildOfficerConfigSource(officersByDivision); + + if (dryRun) { + console.log( + `[DRY RUN] Would write officer export to ${officersExportPath} with ${officerEntries.length} officers` + ); + return; + } + + fs.writeFileSync(officersExportPath, source); + console.log(`✓ Exported officers to TypeScript config: ${officersExportPath}`); +} + +async function clearOutputDirectory() { + if (!fs.existsSync(outputDir)) { + return; + } + + const files = fs.readdirSync(outputDir); + + for (const file of files) { + const filePath = path.join(outputDir, file); + const stat = fs.statSync(filePath); + + if (stat.isFile()) { + fs.unlinkSync(filePath); + console.log(`🗑️ Deleted: ${file}`); + } + } +} + +async function downloadImages() { + try { + console.log('Starting Firebase image sync...'); + console.log(`Output directory: ${outputDir}`); + console.log(`Officers export path: ${officersExportPath}`); + console.log(`Dry run mode: ${dryRun}`); + + const db = admin.firestore(); + const officerSnapshot = await db.collection('officer').get(); + const officerEntries = extractOfficerEntries(officerSnapshot); + const officerDataByUid = {}; + + for (const entry of officerEntries) { + if (!officerDataByUid[entry.uid] && looksLikeOfficerRecord(entry.data)) { + officerDataByUid[entry.uid] = entry.data; + } + } + + // Ensure output directory exists + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Clear existing files in output directory + if (!dryRun) { + console.log('Clearing existing officer images...'); + clearOutputDirectory(); + } else { + console.log('[DRY RUN] Would clear all files from officer directory'); + } + + // List all files in the Firebase storage 'officers' folder + const [files] = await bucket.getFiles({ + prefix: 'officers/', // Assumes images are stored under 'officers/' prefix + }); + + if (files.length === 0) { + console.log('No files found in Firebase Storage under officers/ prefix'); + console.log('Exporting officers by division from Firestore...'); + await exportOfficersByDivision(officerEntries, {}); + process.exit(0); + } + + console.log(`Found ${files.length} files in Firebase Storage`); + const imagePathByUid = {}; + + // Download each file + for (const file of files) { + const fileName = path.basename(file.name); + + // Skip if it's a folder + if (fileName === '') continue; + + // Extract UID from filename (remove extension) + const originalExtension = path.extname(fileName); + const uid = path.basename(fileName, originalExtension); + const resolvedExtension = await resolveFileExtension(file, fileName); + + const officerData = officerDataByUid[uid]; + const officerSlug = officerData ? getOfficerSlugFromData(officerData) : null; + const officerName = officerSlug; + const renamedFile = officerName + ? `${officerName}${resolvedExtension}` + : (originalExtension ? fileName : `${fileName}${resolvedExtension}`); + + const localPath = path.join(outputDir, renamedFile); + imagePathByUid[uid] = `/assets/officer/${renamedFile}`; + + console.log(`Downloading: ${file.name} -> ${localPath}`); + + if (!dryRun) { + await bucket.file(file.name).download({ + destination: localPath, + }); + console.log(`✓ Downloaded: ${renamedFile}`); + } else { + console.log(`[DRY RUN] Would download: ${file.name} as ${renamedFile}`); + } + } + + console.log('Exporting officers by division from Firestore...'); + await exportOfficersByDivision(officerEntries, imagePathByUid); + + console.log('✅ Sync completed successfully!'); + process.exit(0); + + } catch (error) { + console.error('❌ Error during sync:', error); + + // Provide helpful guidance for common errors + if (error.code === 404 && error.message?.includes('bucket does not exist')) { + console.error('\n⚠️ BUCKET NOT FOUND\n'); + console.error('The Firebase Storage bucket could not be found.'); + console.error(`Attempted bucket: ${storageBucket}\n`); + console.error('Common Firebase Storage bucket formats:'); + console.error(` - {project-id}.firebasestorage.app (newer projects)`); + console.error(` - {project-id}.appspot.com (older projects)`); + console.error(` - Custom bucket name\n`); + console.error('To fix this:'); + console.error('1. Check your Firebase Console -> Storage to find the correct bucket name'); + console.error('2. Set the FIREBASE_STORAGE_BUCKET environment variable or GitHub secret'); + console.error('3. Or pass it as a workflow input when manually triggering the action\n'); + } + + process.exit(1); + } +} + +downloadImages(); diff --git a/src/app/officers/page.tsx b/src/app/officers/page.tsx index 9a8e2383..0e119eaf 100644 --- a/src/app/officers/page.tsx +++ b/src/app/officers/page.tsx @@ -1,47 +1,25 @@ import OfficerGrid from '@/components/Officers/OfficerGrid'; import OfficerHeader from '@/components/Officers/OfficerHeader'; -import Image from 'next/image'; function Apply() { return ( -
-
-

- 0 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 0 1 0 0 1 1 0 1 0 0 1 0 0 0 0 0 0 1 0 1 0 0 0 0 0 1 0 1 0 - 0 1 0 0 1 0 0 1 1 1 1 0 1 0 0 1 0 1 0 0 1 0 0 0 1 0 1 0 1 0 0 0 0 1 1 0 1 0 1 0 1 0 0 0 1 - 0 1 0 0 1 1 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 0 1 0 0 1 1 0 1 0 0 1 0 0 0 0 - 0 0 1 0 1 0 0 0 0 0 1 0 1 0 0 1 0 0 1 0 0 1 1 1 1 0 1 0 0 1 0 1 0 0 1 0 0 0 1 0 1 0 1 0 0 - 0 0 1 1 0 1 0 1 0 1 0 0 0 1 0 1 0 0 1 1 -

-

- 0 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 0 1 0 0 1 1 0 1 0 0 1 0 0 0 0 0 0 1 0 1 0 0 0 0 0 1 0 1 0 - 0 1 0 0 1 0 0 1 1 1 1 0 1 0 0 1 0 1 0 0 1 0 0 0 1 0 1 0 1 0 0 0 0 1 1 0 1 0 1 0 1 0 0 0 1 - 0 1 0 0 1 1 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 0 1 0 0 1 1 0 1 0 0 1 0 0 0 0 - 0 0 1 0 1 0 0 0 0 0 1 0 1 0 0 1 0 0 1 0 0 1 1 1 1 0 1 0 0 1 0 1 0 0 1 0 0 0 1 0 1 0 1 0 0 - 0 0 1 1 0 1 0 1 0 1 0 0 0 1 0 1 0 0 1 1 -

-

- 0 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 0 1 0 0 1 1 0 1 0 0 1 0 0 0 0 0 0 1 0 1 0 0 0 0 0 1 0 1 0 - 0 1 0 0 1 0 0 1 1 1 1 0 1 0 0 1 0 1 0 0 1 0 0 0 1 0 1 0 1 0 0 0 0 1 1 0 1 0 1 0 1 0 0 0 1 - 0 1 0 0 1 1 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 1 0 1 0 0 0 0 1 1 0 1 0 0 1 1 0 1 0 0 1 0 0 0 0 - 0 0 1 0 1 0 0 0 0 0 1 0 1 0 0 1 0 0 1 0 0 1 1 1 1 0 1 0 0 1 0 1 0 0 1 0 0 0 1 0 1 0 1 0 0 - 0 0 1 1 0 1 0 1 0 1 0 0 0 1 0 1 0 0 1 1 -

+
+
+
+ +
+ + + + + + + + + + +
-
- Division -
- - - - - - - - - - -
); } diff --git a/src/components/Divisions/Development/DevHeader.tsx b/src/components/Divisions/Development/DevHeader.tsx index 7afba510..75a4af67 100644 --- a/src/components/Divisions/Development/DevHeader.tsx +++ b/src/components/Divisions/Development/DevHeader.tsx @@ -3,7 +3,7 @@ import DivisionHeader from "../Shared/DivisionHeader"; export function DevHeader() { return ( - ACM Development designs, builds, and maintains web applications that support ACM's operations and member interactions. Projects like the Member Portal and UTD Grades ensure reliable and user-friendly platforms for the campus community. + ACM Development designs, builds, and maintains web applications that support ACM&aposs operations and member interactions. Projects like the Member Portal and UTD Grades ensure reliable and user-friendly platforms for the campus community. ); } \ No newline at end of file diff --git a/src/components/Officers/OfficerGrid.tsx b/src/components/Officers/OfficerGrid.tsx index 7966d3e0..0fa6bd66 100644 --- a/src/components/Officers/OfficerGrid.tsx +++ b/src/components/Officers/OfficerGrid.tsx @@ -1,9 +1,10 @@ 'use client'; import Image from 'next/image'; -import { type ReactNode } from 'react'; +import { Github, Linkedin } from 'lucide-react'; +import { type CSSProperties, type ReactNode } from 'react'; import { useState } from 'react'; -import { divisionOfficerMap } from '../../../config/officers.config'; +import { divisionOfficerMap, type Officer } from '../../../config/officers.config'; type Layout = | 'advisor' @@ -21,12 +22,6 @@ type GridProps = { type: Layout; }; -type Officer = { - name: string; - position: string; - image: string; -}; - type PillProps = { officer: Officer; }; @@ -71,12 +66,25 @@ const titleMap: Record = { ), }; +const divisionNameMap: Record = { + advisor: 'Advisor', + board: 'Board', + media: 'Media', + research: 'Research', + development: 'Development', + projects: 'Projects', + education: 'Education', + community: 'Community', + hackutd: 'HackUTD', + industry: 'Industry', +}; + interface OfficerImageWithFallbackProps { src: string; fallbackSrc: string; alt: string; className: string; - style: React.CSSProperties; + style: CSSProperties; isJCole: boolean; } @@ -100,42 +108,133 @@ const OfficerImageWithFallback = (props: OfficerImageWithFallbackProps) => { }; const OfficerGrid = (props: GridProps) => { - const officers = divisionOfficerMap[props.type]; + const officers = [...divisionOfficerMap[props.type]].sort((a, b) => { + const levelA = typeof a.level === 'number' ? a.level : 0; + const levelB = typeof b.level === 'number' ? b.level : 0; + + if (levelA !== levelB) { + return levelB - levelA; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + const officerCountText = `${officers.length} ${officers.length === 1 ? 'officer' : 'officers'} in ${divisionNameMap[props.type]}`; + const shouldShowOfficerCount = props.type !== 'advisor'; + return ( -
-
{titleMap[props.type]}
+
+
{titleMap[props.type]}
+
{officers.map((officer) => ( ))}
+ {shouldShowOfficerCount ? ( +

{officerCountText}

+ ) : null}
); }; -const OfficerPill = ({ officer }: PillProps) => ( -
-
- -
-
-

{officer.name}

-

{officer.position}

+function getSocialIconLinks(socialLinks?: Record) { + if (!socialLinks) { + return []; + } + + const links = { + linkedin: '', + github: '', + }; + + for (const [key, value] of Object.entries(socialLinks)) { + if (typeof value !== 'string' || value.trim() === '') { + continue; + } + + const normalizedKey = key.trim().toLowerCase(); + + if (normalizedKey === 'linkedin' || normalizedKey === 'linkedinurl') { + links.linkedin = value; + continue; + } + + if (normalizedKey === 'github' || normalizedKey === 'githuburl') { + links.github = value; + continue; + } + + } + + return [ + { + key: 'linkedin', + href: links.linkedin, + icon: Linkedin, + label: 'LinkedIn', + }, + { + key: 'github', + href: links.github, + icon: Github, + label: 'GitHub', + }, + ].filter((link) => link.href); +} + +const OfficerPill = ({ officer }: PillProps) => { + const socialIconLinks = getSocialIconLinks(officer.socialLinks); + const isJCole = officer.name === 'John Cole'; + + return ( +
+
+
+ +
+
+
+

+ {officer.name} +

+

{officer.position}

+
+ +
+ {socialIconLinks.length > 0 ? ( +
+ {socialIconLinks.map((socialLink) => { + const Icon = socialLink.icon; + + return ( + + + + ); + })} +
+ ) : null} +
+
+
-
-); + ); +}; export default OfficerGrid; diff --git a/src/components/Officers/OfficerHeader.tsx b/src/components/Officers/OfficerHeader.tsx index e74acd97..640f4ee7 100644 --- a/src/components/Officers/OfficerHeader.tsx +++ b/src/components/Officers/OfficerHeader.tsx @@ -2,15 +2,33 @@ import Image from 'next/image'; const OfficerHeader = () => { return ( -
-
-

meet the team

-

- 8 divisions. One goal. -
here are the students keeping ACM in motion. -

+
+
+
+
+

+ meet the team +

+

+ 8 divisions. One goal. +
+ here are the students keeping ACM in motion. +

+
+
+
+
+ Peechi +
+
-
); }; diff --git a/tsconfig.json b/tsconfig.json index b7f5abd1..95d0ceca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { diff --git a/types/assets.d.ts b/types/assets.d.ts new file mode 100644 index 00000000..297aec44 --- /dev/null +++ b/types/assets.d.ts @@ -0,0 +1,19 @@ +declare module '*.png' { + const src: string; + export default src; +} + +declare module '*.jpg' { + const src: string; + export default src; +} + +declare module '*.jpeg' { + const src: string; + export default src; +} + +declare module '*.webp' { + const src: string; + export default src; +}