Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 78 additions & 9 deletions e2e/cms-integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ async function openFirstProjectFromListing(page: Page): Promise<boolean> {
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<boolean> {
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);
Expand Down Expand Up @@ -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);
});
});

Expand Down
10 changes: 10 additions & 0 deletions src/lib/sanity/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions src/lib/sanity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export * from "./client";
export * from "./fetchers";
export * from "./memberSocials";
export * from "./portableText";
export * from "./teamLogo";
export type * from "./schemas";
1 change: 1 addition & 0 deletions src/lib/sanity/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const teamDetailProjection = `{
name,
slug,
logo,
teamDescription,
members,
"projects": *[_type == "hackathonProject" && references(^._id) && defined(slug.current)] ${projectPreviewProjection}
}`;
Expand Down
1 change: 1 addition & 0 deletions src/lib/sanity/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface Team {
name: string;
slug: Slug;
logo?: SanityImage;
teamDescription?: PortableTextBlock[];
members?: TeamMember[];
}

Expand Down
56 changes: 56 additions & 0 deletions src/lib/sanity/teamLogo.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
21 changes: 21 additions & 0 deletions src/lib/sanity/teamLogo.ts
Original file line number Diff line number Diff line change
@@ -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();
}
61 changes: 50 additions & 11 deletions src/pages/projects/team/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
---

<PageLayout
Expand All @@ -29,17 +37,20 @@ if (!team) return Astro.redirect("/404");
<div class="page-container page-container--narrow">
<div class="team-header">
{
team.logo && (
teamLogo && (
<img
src={urlFor(team.logo).width(200).height(200).url()}
src={teamLogoUrl(teamLogo)}
alt={team.name}
class="team-logo"
width={TEAM_LOGO_MAX_WIDTH}
height={TEAM_LOGO_MAX_HEIGHT}
loading="eager"
decoding="async"
/>
)
}
<h1 class="team-name">{team.name}</h1>
</div>

{
team.members && team.members.length > 0 && (
<section class="members-section">
Expand All @@ -52,7 +63,13 @@ if (!team) return Astro.redirect("/404");
</section>
)
}

{
hasDescription && (
<div class="team-description">
<PortableText value={descriptionBlocks} />
</div>
)
}
{
team.projects && team.projects.length > 0 && (
<section class="projects-section">
Expand All @@ -75,19 +92,38 @@ 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 {
font-size: clamp(2rem, 6vw, 3.5rem);
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;
Expand All @@ -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%;
}
</style>
20 changes: 1 addition & 19 deletions studio-hack-michigan-/schemaTypes/hackathonProject.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
27 changes: 27 additions & 0 deletions studio-hack-michigan-/schemaTypes/portableTextUtils.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading