diff --git a/src/app/components/ProjectCard.tsx b/src/app/components/ProjectCard.tsx index 5d7f5eb..efae0f9 100644 --- a/src/app/components/ProjectCard.tsx +++ b/src/app/components/ProjectCard.tsx @@ -129,7 +129,7 @@ function ProjectTags({project, className}: {project: ProjectSummary; className: ); } -function MemberStack({ +export function MemberStack({ members, emptyLabel = 'up for grabs', }: { diff --git a/src/app/queries/projects.ts b/src/app/queries/projects.ts index 10f4261..f7f3a3a 100644 --- a/src/app/queries/projects.ts +++ b/src/app/queries/projects.ts @@ -118,6 +118,7 @@ export function useSaveProject(projectId?: string, claim = false) { onSuccess: ({project}) => { cache.setQueryData(['project', project.id], {project}); void cache.invalidateQueries({queryKey: ['projects', project.yearId]}); + void cache.invalidateQueries({queryKey: ['year', project.yearId]}); void cache.invalidateQueries({queryKey: ['years']}); }, }); @@ -128,7 +129,10 @@ export function useDeleteProject() { return useMutation({ mutationFn: (projectId: string) => apiRequest(`/projects/${encodeURIComponent(projectId)}`, {method: 'DELETE'}), - onSuccess: () => void cache.invalidateQueries({queryKey: ['projects']}), + onSuccess: () => { + void cache.invalidateQueries({queryKey: ['projects']}); + void cache.invalidateQueries({queryKey: ['year']}); + }, }); } diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx index f10208c..b782ea4 100644 --- a/src/app/routes/ProjectsPage.tsx +++ b/src/app/routes/ProjectsPage.tsx @@ -2,9 +2,10 @@ import {useEffect, useRef, useState} from 'react'; import {Link, useParams, useSearchParams} from 'wouter'; import type {BallotStatusResponse} from '../../shared/administration'; +import type {ProjectSummary} from '../../shared/projects'; import {getAwardCategoryDescription} from '../awardCategories'; import {GroupManager} from '../components/GroupManager'; -import {ProjectCard} from '../components/ProjectCard'; +import {MemberStack, ProjectCard} from '../components/ProjectCard'; import {PageState, QueryState} from '../components/AppLayout'; import {useBallotStatus} from '../queries/administration'; import {useProjects, useYear} from '../queries/projects'; @@ -130,6 +131,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { ? 'browse the finished projects, teams, and award winners.' : 'see what everyone is building, join a team, or share an idea.'}

+
{(isAdmin || year.data.year.submissionsClosed) && ( @@ -348,6 +350,32 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { ); } +function MyProjectsStrip({projects}: {projects: ProjectSummary[]}) { + if (!projects.length) return null; + return ( +
+

yours

+
+ {projects.map((project) => ( + + + {project.kind === 'idea' + ? 'open idea' + : (project.group?.name ?? 'ungrouped')} + + {project.name} + + + ))} +
+
+ ); +} + function selectedCategoriesByProject(ballot?: BallotStatusResponse) { const result = new Map(); if (!ballot) return result; diff --git a/src/app/styles.css b/src/app/styles.css index 0676b1e..a78587e 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -292,7 +292,7 @@ main { letter-spacing: -0.055em; } .archiveHero p:last-child, -.projectsHero > div > p:last-child, +.projectsHero > div > p:last-of-type, .editorPage > header > p:last-child, .operationsHero > p { max-width: 34rem; @@ -301,6 +301,64 @@ main { font-size: 1rem; line-height: 1.75; } +.myProjects { + margin-top: 1.5rem; +} +.myProjects .kicker { + margin-bottom: 0.75rem; +} +.myProjectsTiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); + gap: 0.65rem; +} +.myProjectTile { + display: grid; + gap: 0.4rem; + min-width: 0; + padding: 0.85rem 0.95rem; + color: inherit; + text-decoration: none; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: #fff; + transition: + transform 120ms ease, + box-shadow 120ms ease, + border-color 120ms ease; +} +.myProjectTile:hover { + color: inherit; + border-color: var(--blurple); + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(78, 42, 154, 0.1); +} +.myProjectTile--idea { + background: #fcfaff; +} +.myProjectTile .tag { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} +.myProjectTile strong { + overflow: hidden; + font-size: 0.95rem; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} +.myProjectTile .memberStack, +.myProjectTile .openSeat { + margin-top: 0.15rem; +} +.myProjectTile .memberStack > span { + width: 1.65rem; + height: 1.65rem; +} .currentYearHero { animation: rise 0.35s ease-out both; @@ -923,6 +981,7 @@ main { } .projectRow { display: grid; + position: relative; grid-template-columns: minmax(12rem, 1fr) auto auto; gap: 1rem; align-items: center; @@ -931,6 +990,14 @@ main { border-bottom: 1px solid var(--line); animation: rise 0.3s ease-out both; } +.projectRow:has(h2 a) { + cursor: pointer; +} +.projectRow h2 a::after { + position: absolute; + inset: 0; + content: ''; +} .projectRow h2 { min-width: 0; margin: 0; @@ -995,6 +1062,7 @@ main { } .projectCard { display: flex; + position: relative; min-width: 0; min-height: 18rem; padding: 1.25rem; @@ -1004,6 +1072,7 @@ main { background: #fff; box-shadow: 0 1px 0 rgba(29, 17, 39, 0.03); animation: rise 0.3s ease-out both; + cursor: pointer; transition: box-shadow 120ms ease, transform 120ms ease; @@ -1052,6 +1121,15 @@ main { .projectCard h2 a { text-decoration: none; } +.projectCard h2 a::after { + position: absolute; + inset: 0; + content: ''; +} +.projectCard > .markdown a { + position: relative; + z-index: 1; +} .projectCard > .markdown { display: -webkit-box; overflow: hidden; @@ -3420,6 +3498,9 @@ kbd { align-items: stretch; flex-direction: column; } + .myProjectsTiles { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } .ballotOverview { grid-template-columns: 1fr; gap: 1.75rem; diff --git a/src/shared/projects.ts b/src/shared/projects.ts index 296a8d5..0ce930d 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -72,6 +72,7 @@ export interface YearResponse { year: YearSummary; groups: GroupSummary[]; awards: AwardSummary[]; + myProjects: ProjectSummary[]; } export interface ProjectsResponse { diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts index 4d7af9b..b6c8da0 100644 --- a/src/worker/repositories/projects.ts +++ b/src/worker/repositories/projects.ts @@ -164,6 +164,26 @@ export async function listProjectOptions(db: D1Database, yearId: string) { }; } +export async function listMyProjects(db: D1Database, yearId: string, userId: string) { + const {results} = await db + .prepare( + `${projectSelect()} WHERE p.year_id = ? AND p.status = 'active' + AND (p.creator_id = ? OR EXISTS ( + SELECT 1 FROM project_members pm + WHERE pm.project_id = p.id AND pm.user_id = ? + )) + ORDER BY CASE p.kind WHEN 'project' THEN 0 ELSE 1 END, + p.name COLLATE NOCASE, p.id`, + ) + .bind(yearId, userId, userId) + .all(); + const members = await membersByProjectIds( + db, + results.map(({id}) => id), + ); + return results.map((row) => mapProject(row, members.get(row.id) ?? [])); +} + export async function listProjects( db: D1Database, options: { diff --git a/src/worker/routes/years.ts b/src/worker/routes/years.ts index b49d02b..df344fc 100644 --- a/src/worker/routes/years.ts +++ b/src/worker/routes/years.ts @@ -7,6 +7,7 @@ import {createGroup} from '../repositories/groups'; import { getYear, listGroups, + listMyProjects, listProjectOptions, listYears, } from '../repositories/projects'; @@ -23,9 +24,10 @@ yearsRoutes.get('/', async (c) => { yearsRoutes.get('/:yearId', async (c) => { try { const yearId = c.req.param('yearId'); - const [year, groups, awardResult] = await Promise.all([ + const [year, groups, myProjects, awardResult] = await Promise.all([ getYear(c.env.DB, yearId), listGroups(c.env.DB, yearId), + listMyProjects(c.env.DB, yearId, c.get('user').id), c.env.DB.prepare( `SELECT a.id, a.year_id, a.project_id, p.name project_name, a.category_id, category.name category_name, a.name @@ -47,6 +49,7 @@ yearsRoutes.get('/:yearId', async (c) => { const response: YearResponse = { year, groups, + myProjects, awards: awardResult.results.map((award) => ({ id: award.id, yearId: award.year_id, diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 84434e4..0e512a1 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -206,6 +206,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } if (url.includes('/api/votes?')) { @@ -471,6 +472,58 @@ describe('clickable project routes', () => { expect(listBadge.closest('.projectRow')).toBeTruthy(); }); + it('tiles owned and attached projects under the page title', async () => { + const ownedIdea = { + ...projectFixture, + id: 'owned-idea', + name: 'Postcard idea', + kind: 'idea' as const, + group: null, + members: [], + }; + const attachedProject = { + ...projectFixture, + id: 'attached-project', + name: 'Shared machine', + creator: { + ...projectFixture.creator, + id: 'someone-else', + displayName: 'Someone Else', + }, + }; + mockProjectsOverview({ + votingEnabled: false, + projects: [projectFixture, ownedIdea, attachedProject], + myProjects: [attachedProject, ownedIdea], + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + const heading = await screen.findByRole('heading', {name: 'projects & ideas'}); + const mine = screen.getByRole('region', {name: 'your projects'}); + expect( + heading.compareDocumentPosition(mine) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(within(mine).getByText('yours')).toBeTruthy(); + const attached = within(mine).getByRole('link', {name: /Shared machine/}); + expect(attached.getAttribute('href')).toBe('/years/2026/projects/attached-project'); + expect(within(attached).getByLabelText('Member One')).toBeTruthy(); + expect( + within(mine) + .getByRole('link', {name: /Postcard idea/}) + .getAttribute('href'), + ).toBe('/years/2026/projects/owned-idea'); + expect(within(mine).queryByRole('link', {name: /A small machine/})).toBeNull(); + }); + + it('hides the personal project tiles when none belong to you', async () => { + mockProjectsOverview({votingEnabled: false, projects: [projectFixture]}); + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + expect(screen.queryByRole('region', {name: 'your projects'})).toBeNull(); + }); + it('keeps closed-year browsing and ballot read failures local', async () => { mockProjectsOverview({votingEnabled: false, projects: [projectFixture]}); const closed = renderRoute( @@ -514,6 +567,7 @@ describe('clickable project routes', () => { }, groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}], awards: [], + myProjects: [], }); } return json({ @@ -592,6 +646,7 @@ describe('clickable project routes', () => { }, groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}], awards: [], + myProjects: [], }); } return json({ @@ -654,6 +709,7 @@ describe('clickable project routes', () => { {id: 'group-b', yearId: '2026', name: 'Lunar', projectCount: 1}, ], awards: [], + myProjects: [], }); } @@ -738,6 +794,7 @@ describe('clickable project routes', () => { }, groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}], awards: [], + myProjects: [], }); } return json({ @@ -782,6 +839,7 @@ describe('clickable project routes', () => { }, groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}], awards: [], + myProjects: [], }); } expect(new URL(url, 'https://hackweek.test').searchParams.get('group')).toBe( @@ -837,6 +895,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } @@ -941,6 +1000,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } @@ -1012,6 +1072,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } @@ -1064,6 +1125,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } @@ -1120,6 +1182,7 @@ describe('clickable project routes', () => { }, groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}], awards: [], + myProjects: [], streamMode: 'disabled', }); } @@ -1483,8 +1546,17 @@ describe('clickable project routes', () => { renderRoute(, '/'); - const link = screen.getByRole('link', {name: 'link'}); - const description = link.closest('.markdown'); + const card = screen + .getByRole('heading', {name: 'A small machine'}) + .closest('.projectCard'); + expect(card).toBeTruthy(); + const projectLink = screen.getByRole('link', {name: 'A small machine'}); + const markdownLink = screen.getByRole('link', {name: 'link'}); + expect(projectLink.getAttribute('href')).toBe('/years/2026/projects/project'); + expect(markdownLink.getAttribute('href')).toBe('https://example.com'); + expect(markdownLink.closest('.projectCard')).toBe(card); + expect(projectLink.contains(markdownLink)).toBe(false); + const description = markdownLink.closest('.markdown'); expect(description?.classList.contains('markdown--compact')).toBe(true); expect(description?.getAttribute('title')).toBe(summary); expect(screen.getByText('detail').tagName).toBe('STRONG'); @@ -1714,12 +1786,14 @@ function mockProjectsOverview({ categories = [], votes = [], projects = [], + myProjects = [], ballotError = false, }: { votingEnabled?: boolean; categories?: Array<{id: string; yearId: string; name: string}>; votes?: BallotStatusResponse['votes']; projects?: ProjectDetail[]; + myProjects?: ProjectDetail[]; ballotError?: boolean; }) { fetchMock.mockImplementation(async (input) => { @@ -1738,6 +1812,7 @@ function mockProjectsOverview({ }, groups: [], awards: [], + myProjects, }); } if (url.includes('/api/votes?')) { diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts index 9fe97e2..453371b 100644 --- a/test/projects/projects.test.ts +++ b/test/projects/projects.test.ts @@ -95,6 +95,39 @@ describe('project and history APIs', () => { expect(page.body.projects[0].members).toBeInstanceOf(Array); }); + it('includes owned and attached projects on the year payload', async () => { + const owned = await createProject(memberToken, {name: 'Owned engine'}); + const idea = await createProject(memberToken, { + name: 'Owned postcard', + kind: 'idea', + groupId: null, + }); + await session(outsiderToken); + const outsiderProject = await createProject(outsiderToken, {name: 'Outsider engine'}); + const attached = await createProject(outsiderToken, {name: 'Attached engine'}); + const member = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') + .bind(`project-member-${suffix}`) + .first<{id: string}>(); + await env.DB.prepare( + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)', + ) + .bind(attached.id, member!.id) + .run(); + + const year = await api(`/years/${yearId}`, memberToken); + const outsiderYear = await api(`/years/${yearId}`, outsiderToken); + + expect(year.status).toBe(200); + expect(year.body.myProjects.map((project: {id: string}) => project.id)).toEqual([ + attached.id, + owned.id, + idea.id, + ]); + expect( + outsiderYear.body.myProjects.map((project: {id: string}) => project.id), + ).toEqual([attached.id, outsiderProject.id]); + }); + it('returns ordered year categories and persists ordered project nominations', async () => { const options = await api(`/years/${yearId}/options`, memberToken); const allCategories = await createProject(memberToken, {