diff --git a/src/app/components/ProjectVoting.tsx b/src/app/components/ProjectVoting.tsx
new file mode 100644
index 0000000..75be3a8
--- /dev/null
+++ b/src/app/components/ProjectVoting.tsx
@@ -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
(null);
+ const [statusMessage, setStatusMessage] = useState(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 (
+
+
+
+ {!project.canVote && (
+
+
your project sits this one out
+
+ creators and teammates can’t vote for their own work, but your ballot is still
+ open on every other project.
+
+
+ )}
+
+ {!ballot.categories.length ? (
+
+ award categories are still being set up. check back soon.
+
+ ) : (
+
+ {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 (
+
+
+ {String(index + 1).padStart(2, '0')}
+
+
+
{category.name}
+ {!project.canVote ? (
+
unavailable on your own project
+ ) : selectedHere ? (
+
+ your vote
+
+ ) : selection ? (
+
+ currently on {selection.projectName}
+ {!selection.projectActive && ' (project withdrawn)'}
+
+ ) : (
+
no project selected yet
+ )}
+
+
+ {project.canVote && !selectedHere && !confirming && (
+ {
+ vote.reset();
+ setStatusMessage(null);
+ if (selection) {
+ setConfirmingCategoryId(category.id);
+ } else {
+ submit(category);
+ }
+ }}
+ >
+ {pending
+ ? 'casting your vote…'
+ : selection
+ ? 'move vote here'
+ : `vote for ${category.name}`}
+
+ )}
+
+ {confirming && selection && (
+
+
+ move your {category.name} vote from{' '}
+ {selection.projectName} to{' '}
+ {project.name} ?
+
+
+ {
+ setConfirmingCategoryId(null);
+ vote.reset();
+ }}
+ >
+ cancel
+
+ submit(category, selection)}
+ >
+ {pending ? 'moving your vote…' : 'confirm move'}
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {vote.isPending && (
+
+ {vote.variables?.voteId ? 'moving' : 'casting'} your{' '}
+ {pendingCategory?.name ?? 'award'} vote…
+
+ )}
+ {statusMessage && (
+
+ {statusMessage}
+
+ )}
+ {vote.error && (
+
+ {vote.error.message}
+
+ )}
+
+ );
+}
diff --git a/src/app/queries/administration.ts b/src/app/queries/administration.ts
index 5161b26..285d7bf 100644
--- a/src/app/queries/administration.ts
+++ b/src/app/queries/administration.ts
@@ -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(`/votes?year=${encodeURIComponent(yearId)}`),
+ apiRequest(`/votes?year=${encodeURIComponent(yearId)}`),
+ enabled,
});
}
@@ -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)}),
});
}
@@ -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']});
};
@@ -69,14 +72,6 @@ export function useAdminMutations(yearId: string) {
apiRequest(`/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}>(
@@ -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) {
diff --git a/src/app/routes/AdminPage.tsx b/src/app/routes/AdminPage.tsx
index 8d100d5..ea8a289 100644
--- a/src/app/routes/AdminPage.tsx
+++ b/src/app/routes/AdminPage.tsx
@@ -2,7 +2,6 @@ import {useEffect, useState} from 'react';
import type {FormEvent} from 'react';
import {Link, useParams} from 'wouter';
-import type {AdminProjectSummary} from '../../shared/administration';
import {QueryState} from '../components/AppLayout';
import {useAdminMutations, useAdminYear} from '../queries/administration';
@@ -35,7 +34,6 @@ export function AdminPage() {
actions.year,
actions.category,
actions.removeCategory,
- actions.nominations,
actions.award,
actions.removeAward,
actions.screening,
@@ -119,20 +117,6 @@ export function AdminPage() {
))}
-
- Eligibility
- Project nominations
- {query.data.projects.map((project) => (
-
- actions.nominations.mutate({projectId: project.id, categoryIds})
- }
- />
- ))}
-
Results
Awards
@@ -276,51 +260,6 @@ export function AdminPage() {
);
}
-function NominationEditor({
- project,
- categories,
- onSave,
-}: {
- project: AdminProjectSummary;
- categories: {id: string; name: string}[];
- onSave: (ids: string[]) => void;
-}) {
- const [selected, setSelected] = useState(
- project.nominations.map(({categoryId}) => categoryId),
- );
- useEffect(
- () => setSelected(project.nominations.map(({categoryId}) => categoryId)),
- [project.nominations],
- );
- return (
-
-
{project.name}
-
- {categories.map((category) => (
-
- = 2}
- onChange={(event) =>
- setSelected(
- event.target.checked
- ? [...selected, category.id]
- : selected.filter((id) => id !== category.id),
- )
- }
- />
- {category.name}
-
- ))}
-
-
onSave(selected)}>
- Save
-
-
- );
-}
-
function move(items: string[], from: number, to: number) {
const next = [...items];
const [item] = next.splice(from, 1);
diff --git a/src/app/routes/ProjectDetailsPage.tsx b/src/app/routes/ProjectDetailsPage.tsx
index 8f82d7b..49ecf4f 100644
--- a/src/app/routes/ProjectDetailsPage.tsx
+++ b/src/app/routes/ProjectDetailsPage.tsx
@@ -4,6 +4,8 @@ import {Link, useLocation, useParams} from 'wouter';
import {QueryState} from '../components/AppLayout';
import {Markdown} from '../components/Markdown';
+import {ProjectVoting} from '../components/ProjectVoting';
+import {useBallotStatus} from '../queries/administration';
import {getPlayback, useProjectVideo} from '../queries/videos';
import {ProjectVideoPanel} from '../video/ProjectVideoPanel';
import {
@@ -20,6 +22,8 @@ export function ProjectDetailsPage() {
}>();
const [, navigate] = useLocation();
const project = useProject(projectId);
+ const ballotYearId = project.data?.project.yearId ?? yearId;
+ const ballot = useBallotStatus(ballotYearId, project.data?.project.kind === 'project');
const withdraw = useDeleteProject();
const upload = useUploadMedia(projectId);
const removeMedia = useDeleteMedia(projectId);
@@ -144,6 +148,46 @@ export function ProjectDetailsPage() {
+ {project.data.project.kind === 'project' && ballot.isLoading && (
+