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
2 changes: 0 additions & 2 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {AdminPage} from './routes/AdminPage';
import {EditProjectPage, NewProjectPage} from './routes/ProjectEditorPage';
import {ProjectDetailsPage} from './routes/ProjectDetailsPage';
import {ProjectsPage} from './routes/ProjectsPage';
import {VotingPage} from './routes/VotingPage';
import {YearAdministrationPage} from './routes/YearAdministrationPage';
import {ProjectVideoWatchPage, VideoWatchPage, WatchPage} from './routes/WatchPage';
import {YearsPage} from './routes/YearsPage';
Expand Down Expand Up @@ -72,7 +71,6 @@ export function App() {
<Route path="/admin/years/:yearId">
{session.user.role === 'admin' ? <AdminPage /> : <Redirect to="/years" />}
</Route>
<Route path="/years/:yearId/vote" component={VotingPage} />
<Route path="/years/:yearId/watch/:videoId" component={VideoWatchPage} />
<Route path="/years/:yearId/watch" component={WatchPage} />
<Route path="/years/:yearId/projects/new" component={NewProjectPage} />
Expand Down
21 changes: 21 additions & 0 deletions src/app/components/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ interface ProjectListMember {
export function ProjectCard({
project,
view = 'grid',
voteCategories = [],
}: {
project: ProjectSummary;
view?: 'grid' | 'list';
voteCategories?: string[];
}) {
const projectLink = `/years/${project.yearId}/projects/${project.id}`;

Expand All @@ -26,6 +28,7 @@ export function ProjectCard({
groupName={project.group?.name ?? 'ungrouped'}
members={project.members}
needsHelp={project.needsHelp}
voteCategories={voteCategories}
/>
);
}
Expand All @@ -39,6 +42,7 @@ export function ProjectCard({
<Markdown compact>{project.summary}</Markdown>
<footer>
<MemberStack members={project.members} />
<ProjectVoteBadge categories={voteCategories} />
</footer>
</article>
);
Expand All @@ -55,6 +59,7 @@ export function ProjectListItem({
members,
needsHelp = false,
emptyMemberLabel = 'up for grabs',
voteCategories = [],
}: {
name: string;
href?: string;
Expand All @@ -66,6 +71,7 @@ export function ProjectListItem({
members: ProjectListMember[];
needsHelp?: boolean;
emptyMemberLabel?: string;
voteCategories?: string[];
}) {
return (
<article className={`projectRow projectRow--${kind}`}>
Expand All @@ -86,13 +92,28 @@ export function ProjectListItem({
<div className="projectRowTags">
<span className="tag tag--group">{groupName}</span>
{detail && <span className="tag">{detail}</span>}
<ProjectVoteBadge categories={voteCategories} />
{needsHelp && <strong className="tag tag--help">looking for help</strong>}
</div>
<MemberStack members={members} emptyLabel={emptyMemberLabel} />
</article>
);
}

function ProjectVoteBadge({categories}: {categories: string[]}) {
if (!categories.length) return null;
const count = categories.length;
return (
<strong
className="projectVoteBadge"
aria-label={`${count} of your ${count === 1 ? 'pick' : 'picks'}: ${categories.join(', ')}`}
title={categories.join(', ')}
>
your picks · {count}
</strong>
);
}

function ProjectTags({project, className}: {project: ProjectSummary; className: string}) {
return (
<div className={className}>
Expand Down
198 changes: 198 additions & 0 deletions src/app/components/ProjectVoting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import {useState} from 'react';

import type {
AwardCategorySummary,
BallotSelection,
BallotStatusResponse,
} from '../../shared/administration';
import {useVoteMutation} from '../queries/administration';

export function ProjectVoting({
ballot,
project,
}: {
ballot: BallotStatusResponse;
project: {id: string; name: string; yearId: string; canVote: boolean};
}) {
const vote = useVoteMutation(project.yearId);
const [confirmingCategoryId, setConfirmingCategoryId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const pendingCategory = ballot.categories.find(
(category) => category.id === vote.variables?.input.categoryId,
);

function submit(category: AwardCategorySummary, selection?: BallotSelection) {
setStatusMessage(null);
vote.reset();
vote.mutate(
{
voteId: selection?.id,
input: {
yearId: project.yearId,
projectId: project.id,
categoryId: category.id,
},
},
{
onSuccess: () => {
setConfirmingCategoryId(null);
setStatusMessage(`your ${category.name} vote is now on ${project.name}.`);
},
},
);
}

return (
<section className="projectVoting" aria-labelledby="project-voting-title">
<header>
<div>
<p className="kicker">award ballot</p>
<h2 id="project-voting-title">vote for this project</h2>
</div>
<p>
choose the award categories where {project.name} stands out. each category gets
one project.
</p>
</header>

{!project.canVote && (
<div className="projectVotingOwn" role="status">
<strong>your project sits this one out</strong>
<p>
creators and teammates can’t vote for their own work, but your ballot is still
open on every other project.
</p>
</div>
)}

{!ballot.categories.length ? (
<p className="projectVotingEmpty">
award categories are still being set up. check back soon.
</p>
) : (
<ul className="projectVotingCategories">
{ballot.categories.map((category, index) => {
const selection = ballot.votes.find(
(item) => item.categoryId === category.id,
);
const selectedHere = selection?.projectId === project.id;
const selectedElsewhere = Boolean(selection && !selectedHere);
const confirming = selectedElsewhere && confirmingCategoryId === category.id;
const pending =
vote.isPending && vote.variables?.input.categoryId === category.id;
const state = !project.canVote
? 'unavailable'
: selectedHere
? 'selected'
: selectedElsewhere
? 'elsewhere'
: 'open';

return (
<li
key={category.id}
className={`projectVotingCategory projectVotingCategory--${state}`}
>
<span className="projectVotingNumber" aria-hidden="true">
{String(index + 1).padStart(2, '0')}
</span>
<div className="projectVotingCategoryCopy">
<h3>{category.name}</h3>
{!project.canVote ? (
<p>unavailable on your own project</p>
) : selectedHere ? (
<p>
<strong className="projectVotingSelected">your vote</strong>
</p>
) : selection ? (
<p>
currently on <strong>{selection.projectName}</strong>
{!selection.projectActive && ' (project withdrawn)'}
</p>
) : (
<p>no project selected yet</p>
)}
</div>

{project.canVote && !selectedHere && !confirming && (
<button
type="button"
className={selection ? 'textAction' : 'primaryAction'}
disabled={vote.isPending}
onClick={() => {
vote.reset();
setStatusMessage(null);
if (selection) {
setConfirmingCategoryId(category.id);
} else {
submit(category);
}
}}
>
{pending
? 'casting your vote…'
: selection
? 'move vote here'
: `vote for ${category.name}`}
</button>
)}

{confirming && selection && (
<div
className="projectVotingConfirm"
role="group"
aria-labelledby={`vote-confirm-${category.id}`}
>
<p id={`vote-confirm-${category.id}`} role="status">
move your {category.name} vote from{' '}
<strong>{selection.projectName}</strong> to{' '}
<strong>{project.name}</strong>?
</p>
<div>
<button
type="button"
className="textAction projectVotingCancel"
disabled={vote.isPending}
onClick={() => {
setConfirmingCategoryId(null);
vote.reset();
}}
>
cancel
</button>
<button
type="button"
className="primaryAction projectVotingMoveAction"
disabled={vote.isPending}
onClick={() => submit(category, selection)}
>
{pending ? 'moving your vote…' : 'confirm move'}
</button>
</div>
</div>
)}
</li>
);
})}
</ul>
)}

{vote.isPending && (
<p className="projectVotingFeedback projectVotingFeedback--pending" role="status">
{vote.variables?.voteId ? 'moving' : 'casting'} your{' '}
{pendingCategory?.name ?? 'award'} vote…
</p>
)}
{statusMessage && (
<p className="projectVotingFeedback projectVotingFeedback--success" role="status">
{statusMessage}
</p>
)}
{vote.error && (
<p className="projectVotingFeedback projectVotingFeedback--error" role="alert">
{vote.error.message}
</p>
)}
</section>
);
}
25 changes: 10 additions & 15 deletions src/app/queries/administration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@ import type {
AnalyticsResponse,
AwardSummary,
AwardWriteRequest,
BallotStatusResponse,
ScreeningOrderItem,
VoteSummary,
VoteWriteRequest,
VotingResponse,
YearWriteRequest,
} from '../../shared/administration';
import {apiRequest, jsonRequest} from './api';

export function useVoting(yearId: string) {
const ballotStatusQueryKey = (yearId: string) => ['ballot-status', yearId] as const;

export function useBallotStatus(yearId: string, enabled = true) {
return useQuery({
queryKey: ['voting', yearId],
queryKey: ballotStatusQueryKey(yearId),
queryFn: () =>
apiRequest<VotingResponse>(`/votes?year=${encodeURIComponent(yearId)}`),
apiRequest<BallotStatusResponse>(`/votes?year=${encodeURIComponent(yearId)}`),
enabled,
});
}

Expand All @@ -29,7 +32,7 @@ export function useVoteMutation(yearId: string) {
voteId ? `/votes/${encodeURIComponent(voteId)}` : '/votes',
jsonRequest(voteId ? 'PUT' : 'POST', input),
),
onSuccess: () => void cache.invalidateQueries({queryKey: ['voting', yearId]}),
onSettled: () => cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)}),
});
}

Expand All @@ -45,7 +48,7 @@ export function useAdminMutations(yearId: string) {
const cache = useQueryClient();
const refresh = () => {
void cache.invalidateQueries({queryKey: ['admin-year', yearId]});
void cache.invalidateQueries({queryKey: ['voting', yearId]});
void cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)});
void cache.invalidateQueries({queryKey: ['year', yearId]});
void cache.invalidateQueries({queryKey: ['years']});
};
Expand All @@ -69,14 +72,6 @@ export function useAdminMutations(yearId: string) {
apiRequest<void>(`/admin/categories/${encodeURIComponent(id)}`, {method: 'DELETE'}),
onSuccess: refresh,
});
const nominations = useMutation({
mutationFn: ({projectId, categoryIds}: {projectId: string; categoryIds: string[]}) =>
apiRequest(
`/admin/projects/${encodeURIComponent(projectId)}/nominations`,
jsonRequest('PUT', {categoryIds}),
),
onSuccess: refresh,
});
const award = useMutation({
mutationFn: ({id, input}: {id?: string; input: AwardWriteRequest}) =>
apiRequest<{award: AwardSummary}>(
Expand All @@ -100,7 +95,7 @@ export function useAdminMutations(yearId: string) {
),
onSuccess: refresh,
});
return {year, category, removeCategory, nominations, award, removeAward, screening};
return {year, category, removeCategory, award, removeAward, screening};
}

export function useAnalytics(yearId?: string) {
Expand Down
Loading
Loading