Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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