diff --git a/e2e/cms-integration.spec.ts b/e2e/cms-integration.spec.ts index a61bac4..a52b4cc 100644 --- a/e2e/cms-integration.spec.ts +++ b/e2e/cms-integration.spec.ts @@ -44,6 +44,24 @@ async function openFirstProjectFromListing(page: Page): Promise { return true; } +/** + * Open the first team page via a project detail's team link. + * Returns false when CMS has no project/team data (unless E2E_REQUIRE_CMS_DATA). + */ +async function openFirstTeamPage(page: Page): Promise { + const hasProject = await openFirstProjectFromListing(page); + if (!hasProject) return false; + + const teamLink = page.locator(".team-link").first(); + await expect(teamLink).toBeVisible(); + const href = await teamLink.getAttribute("href"); + expect(href).toMatch(/^\/projects\/team\/[^/]+\/$/); + await gotoPath(page, href!); + await expect(page).toHaveURL(/\/projects\/team\/[^/]+\/$/); + await expect(page.locator(".team-name")).toBeVisible(); + return true; +} + test.describe("Projects Page", () => { test("renders project cards or empty state", async ({ page }) => { await gotoPath(page, PROJECTS_PATH); @@ -86,16 +104,67 @@ test.describe("Project Detail Page", () => { test.describe("Team Detail Page", () => { test("team detail is reachable from project team link", async ({ page }) => { - const hasProject = await openFirstProjectFromListing(page); - if (!hasProject) return; + await openFirstTeamPage(page); + }); + + test("team page content uses the narrow page container", async ({ page }) => { + const hasTeam = await openFirstTeamPage(page); + if (!hasTeam) return; + + const narrow = page.locator(".page-container--narrow"); + await expect(narrow).toBeVisible(); + await expect(narrow.locator(".team-name")).toBeVisible(); + + const projectsSection = page.locator(".projects-section"); + const projectsCount = await projectsSection.count(); + expect(projectsCount).toBeLessThanOrEqual(1); + if (projectsCount > 0) { + await expect(narrow.locator(".projects-section")).toBeVisible(); + } + }); + + test("team projects section renders compact project cards when present", async ({ + page, + }) => { + const hasTeam = await openFirstTeamPage(page); + if (!hasTeam) return; + + const projectsSection = page.locator(".projects-section"); + if ((await projectsSection.count()) === 0) return; + + await expect(projectsSection.locator("h2")).toContainText("Projects"); + const compactCards = projectsSection.locator("a.project-card--compact"); + await expect(compactCards.first()).toBeVisible(); + expect(await compactCards.count()).toBeGreaterThan(0); + }); + + test("team description renders portable text when present", async ({ + page, + }) => { + const hasTeam = await openFirstTeamPage(page); + if (!hasTeam) return; + + const descriptionParagraphs = page.locator( + ".page-container--narrow .team-description p", + ); + if ((await descriptionParagraphs.count()) === 0) return; + + await expect(descriptionParagraphs.first()).toBeVisible(); + }); + + test("team members section renders member cards when present", async ({ + page, + }) => { + const hasTeam = await openFirstTeamPage(page); + if (!hasTeam) return; + + const membersSection = page.locator(".members-section"); + if ((await membersSection.count()) === 0) return; - const teamLink = page.locator(".team-link").first(); - await expect(teamLink).toBeVisible(); - const href = await teamLink.getAttribute("href"); - expect(href).toMatch(/^\/projects\/team\/[^/]+\/$/); - await gotoPath(page, href!); - await expect(page).toHaveURL(/\/projects\/team\/[^/]+\/$/); - await expect(page.locator(".team-name")).toBeVisible(); + await expect(membersSection.locator("h2")).toContainText("Team Members"); + const memberCards = membersSection.locator(".member-card"); + await expect(memberCards.first()).toBeVisible(); + expect(await memberCards.count()).toBeGreaterThan(0); }); }); diff --git a/src/lib/sanity/client.ts b/src/lib/sanity/client.ts index fcf9abc..feea423 100644 --- a/src/lib/sanity/client.ts +++ b/src/lib/sanity/client.ts @@ -17,3 +17,13 @@ const builder = createImageUrlBuilder(sanityClient); export function urlFor(source: SanityImage) { return builder.image(source); } + +/** True when a Sanity image field has a resolvable asset (optional fields may be unset or empty). */ +export function hasSanityImage( + image: SanityImage | null | undefined, +): image is SanityImage { + if (!image || typeof image !== "object") return false; + const asset = (image as { asset?: { _ref?: string; _id?: string } }).asset; + if (!asset || typeof asset !== "object") return false; + return Boolean(asset._ref ?? asset._id); +} diff --git a/src/lib/sanity/index.ts b/src/lib/sanity/index.ts index 8bff952..07300a5 100644 --- a/src/lib/sanity/index.ts +++ b/src/lib/sanity/index.ts @@ -9,4 +9,5 @@ export * from "./client"; export * from "./fetchers"; export * from "./memberSocials"; export * from "./portableText"; +export * from "./teamLogo"; export type * from "./schemas"; diff --git a/src/lib/sanity/queries.ts b/src/lib/sanity/queries.ts index fb398af..b56dc85 100644 --- a/src/lib/sanity/queries.ts +++ b/src/lib/sanity/queries.ts @@ -46,6 +46,7 @@ export const teamDetailProjection = `{ name, slug, logo, + teamDescription, members, "projects": *[_type == "hackathonProject" && references(^._id) && defined(slug.current)] ${projectPreviewProjection} }`; diff --git a/src/lib/sanity/schemas.ts b/src/lib/sanity/schemas.ts index 0d9f924..1aa91fc 100644 --- a/src/lib/sanity/schemas.ts +++ b/src/lib/sanity/schemas.ts @@ -43,6 +43,7 @@ export interface Team { name: string; slug: Slug; logo?: SanityImage; + teamDescription?: PortableTextBlock[]; members?: TeamMember[]; } diff --git a/src/lib/sanity/teamLogo.test.ts b/src/lib/sanity/teamLogo.test.ts new file mode 100644 index 0000000..9c2a5ef --- /dev/null +++ b/src/lib/sanity/teamLogo.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; + +const chain = { + width: vi.fn().mockReturnThis(), + height: vi.fn().mockReturnThis(), + fit: vi.fn().mockReturnThis(), + url: vi.fn(() => "https://cdn.sanity.io/team-logo.webp"), +}; + +vi.mock("./client", () => ({ + urlFor: vi.fn(() => chain), + hasSanityImage: vi.fn((logo: unknown) => { + if (!logo || typeof logo !== "object") return false; + const asset = (logo as { asset?: { _ref?: string; _id?: string } }).asset; + if (!asset || typeof asset !== "object") return false; + return Boolean(asset._ref ?? asset._id); + }), +})); + +import { + hasTeamLogo, + teamLogoUrl, + TEAM_LOGO_MAX_HEIGHT, + TEAM_LOGO_MAX_WIDTH, +} from "./teamLogo"; + +describe("hasTeamLogo", () => { + it("returns false when logo is missing or has no asset", () => { + expect(hasTeamLogo(undefined)).toBe(false); + expect(hasTeamLogo(null)).toBe(false); + expect(hasTeamLogo({ _type: "image" })).toBe(false); + expect(hasTeamLogo({ _type: "image", asset: {} })).toBe(false); + }); + + it("returns true when logo has an asset reference (_ref)", () => { + expect(hasTeamLogo({ _type: "image", asset: { _ref: "image-abc" } })).toBe( + true, + ); + }); + + it("returns true when logo has an expanded asset ID (_id)", () => { + expect(hasTeamLogo({ _type: "image", asset: { _id: "image-xyz" } })).toBe( + true, + ); + }); +}); + +describe("teamLogoUrl", () => { + it("requests the wide logo bounds without cropping", () => { + const image = { _type: "image", asset: { _ref: "image-abc" } }; + expect(teamLogoUrl(image)).toBe("https://cdn.sanity.io/team-logo.webp"); + expect(chain.width).toHaveBeenCalledWith(TEAM_LOGO_MAX_WIDTH); + expect(chain.height).toHaveBeenCalledWith(TEAM_LOGO_MAX_HEIGHT); + expect(chain.fit).toHaveBeenCalledWith("max"); + }); +}); diff --git a/src/lib/sanity/teamLogo.ts b/src/lib/sanity/teamLogo.ts new file mode 100644 index 0000000..50ca73e --- /dev/null +++ b/src/lib/sanity/teamLogo.ts @@ -0,0 +1,21 @@ +import { urlFor, hasSanityImage } from "./client"; +import type { SanityImage } from "./schemas"; + +/** Recommended upload size for `team.logo` in Sanity — keep in sync with studio field description. */ +export const TEAM_LOGO_MAX_WIDTH = 600; +export const TEAM_LOGO_MAX_HEIGHT = 300; + +/** + * Domain-specific alias for `hasSanityImage`. + * True when Sanity returned a team logo with a resolvable asset reference. + */ +export const hasTeamLogo = hasSanityImage; + +/** Build a Sanity CDN URL that fits within the logo bounds without square-cropping. */ +export function teamLogoUrl(image: SanityImage): string { + return urlFor(image) + .width(TEAM_LOGO_MAX_WIDTH) + .height(TEAM_LOGO_MAX_HEIGHT) + .fit("max") + .url(); +} diff --git a/src/pages/projects/team/[slug].astro b/src/pages/projects/team/[slug].astro index f446342..3bc6e64 100644 --- a/src/pages/projects/team/[slug].astro +++ b/src/pages/projects/team/[slug].astro @@ -2,12 +2,16 @@ import { getTeam, getTeamSlugs, - urlFor, + hasTeamLogo, + teamLogoUrl, + TEAM_LOGO_MAX_HEIGHT, + TEAM_LOGO_MAX_WIDTH, type TeamWithProjects, } from "../../../lib/sanity"; import PageLayout from "../../../layouts/PageLayout.astro"; import MemberCard from "../../../components/cms/MemberCard.astro"; import ProjectCard from "../../../components/cms/ProjectCard.astro"; +import { PortableText } from "astro-portabletext"; export async function getStaticPaths() { const slugs = await getTeamSlugs(); @@ -19,6 +23,10 @@ if (!slug) return Astro.redirect("/404"); const team: TeamWithProjects | null = await getTeam(slug); if (!team) return Astro.redirect("/404"); + +const descriptionBlocks = team.teamDescription ?? []; +const hasDescription = descriptionBlocks.length > 0; +const teamLogo = hasTeamLogo(team.logo) ? team.logo : undefined; ---
{ - team.logo && ( + teamLogo && ( ) }

{team.name}

- { team.members && team.members.length > 0 && (
@@ -52,7 +63,13 @@ if (!team) return Astro.redirect("/404");
) } - + { + hasDescription && ( +
+ +
+ ) + } { team.projects && team.projects.length > 0 && (
@@ -75,12 +92,14 @@ if (!team) return Astro.redirect("/404"); } .team-logo { - width: 150px; - height: 150px; - border-radius: 50%; - object-fit: cover; + display: block; + width: auto; + height: 10rem; + max-width: 100%; + margin-inline: auto; margin-bottom: 1.5rem; - border: 2px solid rgba(255, 255, 255, 0.1); + object-fit: contain; + border-radius: 0.5rem; } .team-name { @@ -88,6 +107,23 @@ if (!team) return Astro.redirect("/404"); font-weight: 800; } + .team-description { + margin-block: 4rem; + margin-inline: 0.5rem; + max-width: 100%; + line-height: 1.6; + color: var(--color-text); + font-size: var(--font-size-body); + } + + .team-description :global(p) { + margin: 0 0 1rem; + } + + .team-description :global(p:last-child) { + margin-bottom: 0; + } + .members-section, .projects-section { margin-bottom: 4rem; @@ -108,7 +144,10 @@ if (!team) return Astro.redirect("/404"); .project-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(280px, 320px)); + justify-content: start; gap: 1.5rem; + width: fit-content; + max-width: 100%; } diff --git a/studio-hack-michigan-/schemaTypes/hackathonProject.ts b/studio-hack-michigan-/schemaTypes/hackathonProject.ts index 918b1cd..0370c23 100644 --- a/studio-hack-michigan-/schemaTypes/hackathonProject.ts +++ b/studio-hack-michigan-/schemaTypes/hackathonProject.ts @@ -1,27 +1,9 @@ import {defineField, defineType} from 'sanity' +import {portableTextPlainLength} from './portableTextUtils' /** Plain-text character cap for portable text `description` — keep in sync with `src/lib/portableText.ts`. */ const DESCRIPTION_MAX_CHARS = 5000 -function portableTextPlainLength(blocks: unknown): number { - if (!Array.isArray(blocks)) return 0 - let len = 0 - for (const block of blocks as {_type?: string; children?: unknown[]}[]) { - if (block?._type !== 'block' || !Array.isArray(block.children)) continue - for (const child of block.children) { - if ( - child && - typeof child === 'object' && - 'text' in child && - typeof (child as {text: unknown}).text === 'string' - ) { - len += (child as {text: string}).text.length - } - } - } - return len -} - export const hackathonProjectType = defineType({ name: 'hackathonProject', title: 'Hackathon Project', diff --git a/studio-hack-michigan-/schemaTypes/portableTextUtils.ts b/studio-hack-michigan-/schemaTypes/portableTextUtils.ts new file mode 100644 index 0000000..e732b8e --- /dev/null +++ b/studio-hack-michigan-/schemaTypes/portableTextUtils.ts @@ -0,0 +1,27 @@ +/** + * Shared portable-text utilities for Sanity studio schema validators. + * + * Extracted so both `hackathonProject` and `team` schemas use the same + * character-counting logic. Keep in sync with `src/lib/sanity/portableText.ts` + * on the Astro side. + */ + +/** Count plain-text characters across portable-text blocks. */ +export function portableTextPlainLength(blocks: unknown): number { + if (!Array.isArray(blocks)) return 0 + let len = 0 + for (const block of blocks as {_type?: string; children?: unknown[]}[]) { + if (block?._type !== 'block' || !Array.isArray(block.children)) continue + for (const child of block.children) { + if ( + child && + typeof child === 'object' && + 'text' in child && + typeof (child as {text: unknown}).text === 'string' + ) { + len += (child as {text: string}).text.length + } + } + } + return len +} diff --git a/studio-hack-michigan-/schemaTypes/team.ts b/studio-hack-michigan-/schemaTypes/team.ts index c1cfb28..0047b5d 100644 --- a/studio-hack-michigan-/schemaTypes/team.ts +++ b/studio-hack-michigan-/schemaTypes/team.ts @@ -1,4 +1,7 @@ import {defineField, defineType} from 'sanity' +import {portableTextPlainLength} from './portableTextUtils' + +const TEAM_DESCRIPTION_MAX_CHARS = 1000 export const teamType = defineType({ name: 'team', @@ -19,8 +22,25 @@ export const teamType = defineType({ defineField({ name: 'logo', type: 'image', + title: 'Team Logo (Optional)', + description: + 'Optional. Wide logo recommended: 600×300 px (or similar 2:1 aspect). PNG or SVG with a transparent background works well.', options: {hotspot: true}, }), + defineField({ + name: 'teamDescription', + type: 'array', + of: [{type: 'block'}], + title: 'Team Description (Optional)', + description: `Rich text. Plain text must be ${TEAM_DESCRIPTION_MAX_CHARS} characters or fewer.`, + validation: (Rule) => + Rule.custom((blocks) => { + const len = portableTextPlainLength(blocks) + return len <= TEAM_DESCRIPTION_MAX_CHARS + ? true + : `Team description is too long (${len} characters). Maximum is ${TEAM_DESCRIPTION_MAX_CHARS}.` + }), + }), defineField({ name: 'members', title: 'Team Members',