diff --git a/components/clubs/ClubDetail.tsx b/components/clubs/ClubDetail.tsx index 4e6f964..18a9661 100644 --- a/components/clubs/ClubDetail.tsx +++ b/components/clubs/ClubDetail.tsx @@ -1,24 +1,156 @@ 'use client'; -import Image from 'next/image'; -import { ctfSrc } from '@/lib/contentful-src'; import Link from 'next/link'; -import { documentToReactComponents, Options } from '@contentful/rich-text-react-renderer'; -import { BLOCKS } from '@contentful/rich-text-types'; -import { Globe, ArrowLeft, ExternalLink } from 'lucide-react'; +import { documentToReactComponents } from '@contentful/rich-text-react-renderer'; +import { BLOCKS, type TopLevelBlock } from '@contentful/rich-text-types'; +import { ArrowLeft, ArrowUpRight } from 'lucide-react'; import { ClubItems } from '@/hooks/useClubs'; import Container from '../custom/Container'; -import { richTextParagraphRenderer } from '@/lib/richTextOptions'; +import { proseRichTextOptions, type EmbeddedAsset } from '@/lib/richTextOptions'; +import { contentfulImage } from '@/lib/contentful-image'; +import ClubLogo from './ClubLogo'; import { useStore } from '@/store/useStore'; -import { useEffect } from 'react'; +import { Fragment, useEffect } from 'react'; interface ClubDetailProps { club: ClubItems; } +type ContentGroup = { kind: 'nodes'; nodes: TopLevelBlock[] } | { kind: 'assets'; ids: string[] }; + +function groupAssetRuns(nodes: TopLevelBlock[]): ContentGroup[] { + const groups: ContentGroup[] = []; + for (const node of nodes) { + const id = node.nodeType === BLOCKS.EMBEDDED_ASSET ? node.data?.target?.sys?.id : undefined; + const last = groups[groups.length - 1]; + if (id) { + if (last?.kind === 'assets') last.ids.push(id); + else groups.push({ kind: 'assets', ids: [id] }); + } else if (last?.kind === 'nodes') { + last.nodes.push(node); + } else { + groups.push({ kind: 'nodes', nodes: [node] }); + } + } + return groups; +} + +interface AssetGridProps { + ids: string[]; + findAsset: (id: string) => EmbeddedAsset | undefined; +} + +const isPortrait = (asset: EmbeddedAsset) => Boolean(asset.width && asset.height && asset.height > asset.width); + +const AssetGrid = ({ ids, findAsset }: AssetGridProps) => { + const items = ids.map(findAsset).filter((asset): asset is EmbeddedAsset => Boolean(asset?.url)); + if (items.length === 0) return null; + + const single = items.length === 1; + const captioned = items.every((asset) => asset.description); + const people = captioned && (!single || isPortrait(items[0])); + + if (single && !people) { + const image = contentfulImage(items[0].url, { intrinsicWidth: items[0].width, intrinsicHeight: items[0].height }); + return ( +
+ {items[0].description + {items[0].description && ( +
{items[0].description}
+ )} +
+ ); + } + + const aspect = people ? 4 / 5 : 4 / 3; + const columns = people + ? single ? 'grid-cols-1 max-w-[240px]' : 'grid-cols-2 sm:grid-cols-3' + : 'grid-cols-1 sm:grid-cols-2'; + + return ( +
+ {items.map((asset) => { + const image = contentfulImage(asset.url, { + widths: [320, 480, 640, 960], + intrinsicWidth: asset.width, + aspect, + focus: people ? 'face' : 'center', + }); + return ( +
+
+ {asset.title +
+ {people && ( +
+

{asset.title}

+ {asset.description && ( +

{asset.description}

+ )} +
+ )} +
+ ); + })} +
+ ); +}; + +interface JoinCardProps { + clubLink: string; + recruiting: boolean; +} + +const RecruitingStatus = ({ recruiting }: { recruiting: boolean }) => ( +

+ + {recruiting ? 'Accepting new members' : 'Recruitment currently closed'} +

+); + +const JoinButton = ({ clubLink, className = '' }: { clubLink: string; className?: string }) => ( + + Join the community + + +); + +const JoinCard = ({ clubLink, recruiting }: JoinCardProps) => ( +
+

Interested?

+ +

Membership is open to all GESA students.

+ +
+); + const ClubDetail: React.FC = ({ club }) => { - const { clubName, clubLogo, clubType, clubLink, isActivelyRecruitingMembers, aboutclub, sys } = club; + const { clubName, clubLogo, clubType, clubLink, description, isActivelyRecruitingMembers, aboutclub, sys } = club; const { addToRecentlyViewed } = useStore(); useEffect(() => { @@ -27,152 +159,74 @@ const ClubDetail: React.FC = ({ club }) => { } }, [club, sys.id, addToRecentlyViewed]); - const options: Options = { - renderNode: { - ...richTextParagraphRenderer, - [BLOCKS.EMBEDDED_ASSET]: (node: any) => { - const assetId = node.data.target.sys.id; - const asset = aboutclub?.links?.assets?.block?.find((a: any) => a.sys.id === assetId); - - if (!asset) return null; - - return ( -
- {asset.title -
- ); - }, - }, - }; - - const QuickOverview = () => ( -
-
- Category -

{clubType}

-
- -
- Membership -

Open to all GESA Students

-
- -
- {isActivelyRecruitingMembers ? ( -
-
-

- Currently accepting new members for the academic year. -

-
- ) : ( -
-
-

- Membership recruitment is currently closed. Please check back later. -

-
- )} -
-
- ); + const assets = aboutclub?.links?.assets?.block ?? []; + const options = proseRichTextOptions(assets); + const findAsset = (id: string) => assets.find((asset) => asset.sys.id === id); return ( -
- {/* Hero Section */} -
- {clubLogo.title -
- -
-
+
+
+ +
- - Back to Clubs and Societies + + All clubs and societies -
- {clubName} + -
- -
-
- {clubType} +
+ + {clubType} + +

+ {clubName} +

+ {description && ( +

{description}

+ )}
-

- {clubName} -

-
-
-
- - -
- {/* Main Content */} -
-
-
- -

- - About the Community -

- -
- {aboutclub && aboutclub.json && documentToReactComponents(aboutclub.json, options)} -
- {/* Mobile Quick Overview - Visible only on mobile */} -
-

Quick Overview

- -
- - +
+ +
+ + - {/* Sidebar - Visible only on desktop */} -
-
-

Quick Overview

- + +
+
+ {aboutclub?.json && + groupAssetRuns(aboutclub.json.content).map((group, index) => + group.kind === 'assets' ? ( + + ) : ( + + {documentToReactComponents({ ...aboutclub.json, content: group.nodes }, options)} + + ) + )} +
+ +
+
diff --git a/components/clubs/ClubLogo.tsx b/components/clubs/ClubLogo.tsx new file mode 100644 index 0000000..0c2c600 --- /dev/null +++ b/components/clubs/ClubLogo.tsx @@ -0,0 +1,58 @@ +import Image from 'next/image'; +import { ctfSrc } from '@/lib/contentful-src'; +import { cn } from '@/lib/utils'; + +const SKIP_WORDS = new Set(['of', 'and', 'the', 'for', 'in', 'knust', '&', '-']); + +export function clubMonogram(name: string) { + const abbreviation = name.match(/\(([^)]+)\)/)?.[1]?.trim(); + if (abbreviation && abbreviation.length <= 8) return abbreviation; + + const initials = name + .split(/\s+/) + .filter((word) => word && !SKIP_WORDS.has(word.toLowerCase())) + .map((word) => word[0]) + .join('') + .toUpperCase(); + + return initials.slice(0, 3) || name.slice(0, 2).toUpperCase(); +} + +interface ClubLogoProps { + name: string; + url?: string | null; + alt?: string | null; + width: number; + className?: string; + imageClassName?: string; + monogramClassName?: string; +} + +const ClubLogo = ({ name, url, alt, width, className, imageClassName, monogramClassName }: ClubLogoProps) => { + if (url) { + return ( +
+ {alt +
+ ); + } + + return ( +
+
+
+ + {clubMonogram(name)} + +
+ ); +}; + +export default ClubLogo; diff --git a/components/clubs/ClubsList.tsx b/components/clubs/ClubsList.tsx index b5c54c9..a2372f8 100644 --- a/components/clubs/ClubsList.tsx +++ b/components/clubs/ClubsList.tsx @@ -1,11 +1,11 @@ 'use client'; import { useEffect, useState } from 'react' -import { ctfSrc } from '@/lib/contentful-src'; import Container from '@/components/custom/Container'; import { Search } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import { ClubItems } from '@/hooks/useClubs'; +import ClubLogo from './ClubLogo'; import academicIcon from '@/public/images/academic.svg' import creativeIcon from '@/public/images/social.svg' import leadershipIcon from '@/public/images/leadership&S.svg' @@ -101,9 +101,15 @@ const ClubsList = ({ clubs }: ClubsListProps) => {
{filteredClubs.filter((club) => club.isFeatured)?.map((club, index) => ( -
- {club.clubLogo.title -
+

{club.clubName}

{club.description}

diff --git a/components/home/HomeClubsPreview.tsx b/components/home/HomeClubsPreview.tsx index a7e2c56..aa60234 100644 --- a/components/home/HomeClubsPreview.tsx +++ b/components/home/HomeClubsPreview.tsx @@ -1,12 +1,11 @@ 'use client' import Container from '../custom/Container' -import { ctfSrc } from '@/lib/contentful-src'; import { Button } from '../ui/button' import { ArrowRight } from 'lucide-react' import Link from 'next/link' import { useClubs } from '@/hooks/useClubs' import type { ClubItems } from '@/lib/data/club' -import Image from 'next/image' +import ClubLogo from '../clubs/ClubLogo' import EmptyState from '../events/EmptyState' import { Users, TriangleAlert } from 'lucide-react' @@ -73,15 +72,14 @@ const HomeClubsPreview = ({ initialClubs }: { initialClubs?: ClubItems[] }) => { key={club.sys?.id ?? index} className='bg-white p-8 rounded-3xl shadow-sm border border-slate-100 hover:shadow-xl transition-all duration-500 flex flex-col gap-6 group hover:-translate-y-1' > -
- -
+

{club.clubName}

{club.description}