Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ export const switchActiveDisplayResolver: GraphQLFieldResolver<
try {
await authorizeAudienceDisplayAccess(context, divisionId);

// Safety check: prevent switching to awards mode before awards have been assigned
if (newDisplay === 'awards') {
const division = await db.divisions.byId(divisionId).get();
if (!division?.awards_assigned) {
throw new MutationError(
MutationErrorCode.CONFLICT,
'Cannot switch to awards display mode before awards have been assigned'
);
}
}

// Update the division's active display in MongoDB
const result = await db.raw.mongo.collection<DivisionState>('division_states').findOneAndUpdate(
{ divisionId },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
FormControlLabel,
Switch,
Box,
Typography
Typography,
Select,
MenuItem,
FormControl,
InputLabel
} from '@mui/material';
import toast from 'react-hot-toast';
import { useMutation } from '@apollo/client/react';
Expand All @@ -35,6 +39,11 @@ export function AudienceDisplaySettingsModal({ open, onClose }: AudienceDisplayS
(audienceDisplay?.settings?.message?.value as string) || ''
);

const [awardWinnerSlideStyle, setAwardWinnerSlideStyle] = useState<'chroma' | 'full' | 'both'>(
(audienceDisplay?.settings?.awards?.awardWinnerSlideStyle as 'chroma' | 'full' | 'both') ||
'both'
);

const [updateAudienceDisplaySetting] = useMutation(UPDATE_AUDIENCE_DISPLAY_SETTING_MUTATION, {
onError: () => {
toast.error(t('errors.update-failed'));
Expand Down Expand Up @@ -81,6 +90,18 @@ export function AudienceDisplaySettingsModal({ open, onClose }: AudienceDisplayS
}
});

const handleAwardWinnerSlideStyleChange = async (style: 'chroma' | 'full' | 'both') => {
setAwardWinnerSlideStyle(style);
await updateAudienceDisplaySetting({
variables: {
divisionId: currentDivision.id,
display: 'awards',
settingKey: 'awardWinnerSlideStyle',
settingValue: style
}
});
};

return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{t('title')}</DialogTitle>
Expand Down Expand Up @@ -149,6 +170,29 @@ export function AudienceDisplaySettingsModal({ open, onClose }: AudienceDisplayS
/>
</Stack>
</Box>

<Box>
<Typography
variant="subtitle2"
sx={{ mb: 1.5, fontWeight: 600, color: 'text.primary' }}
>
{t('sections.awards')}
</Typography>
<FormControl fullWidth size="small">
<InputLabel>{t('fields.award-winner-slide-style')}</InputLabel>
<Select
value={awardWinnerSlideStyle}
label={t('fields.award-winner-slide-style')}
onChange={e =>
handleAwardWinnerSlideStyleChange(e.target.value as 'chroma' | 'full' | 'both')
}
>
<MenuItem value="chroma">{t('fields.award-slide-style-chroma')}</MenuItem>
<MenuItem value="full">{t('fields.award-slide-style-full')}</MenuItem>
<MenuItem value="both">{t('fields.award-slide-style-both')}</MenuItem>
</Select>
</FormControl>
</Box>
</Stack>
</DialogContent>
<DialogActions>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'use client';

import React, { useRef, useCallback, useMemo } from 'react';
import { Stack } from '@mui/material';
import { DeckRef } from '@lems/presentations';
import toast from 'react-hot-toast';
import { useMutation } from '@apollo/client/react';
import { AwardsDisplay } from '../../../audience-display/components/awards-display';
import { UPDATE_AUDIENCE_DISPLAY_SETTING_MUTATION } from '../graphql';
import { useEvent } from '../../../components/event-context';
import { useScorekeeperData } from './scorekeeper-context';
import { PresentationController } from './presentation-controller';

interface Award {
id: string;
name: string;
index: number;
place: number;
type: string;
isOptional: boolean;
winner?: {
id: string;
name?: string;
number?: number;
affiliation?: { id: string; name: string; city: string };
};
}

interface ScorekeeperContextData {
field: {
audienceDisplay?: {
settings?: {
awards?: {
awardWinnerSlideStyle?: 'chroma' | 'full' | 'both';
presentationState?: {
slideIndex: number;
stepIndex: number;
};
};
};
};
judging?: {
awards: Award[];
};
};
}

export function AwardsPresentationWrapper() {
const deckRef = useRef<DeckRef | null>(null);
const { currentDivision } = useEvent();
const data = useScorekeeperData() as unknown as ScorekeeperContextData;
const field = data?.field;

const [updateAudienceDisplaySetting] = useMutation(UPDATE_AUDIENCE_DISPLAY_SETTING_MUTATION, {
onError: () => {
toast.error('Failed to update presentation state');
}
});

const awardWinnerSlideStyle =
field?.audienceDisplay?.settings?.awards?.awardWinnerSlideStyle || 'both';
const presentationState = field?.audienceDisplay?.settings?.awards?.presentationState || {
slideIndex: 0,
stepIndex: 0
};

const awards = useMemo(() => field?.judging?.awards ?? [], [field?.judging?.awards]);

// Calculate total slides: title + awards grouped by index
const totalSlides = useMemo(() => {
if (!awards.length) return 0;
const uniqueIndices = new Set(awards.map((a: Award) => a.index));
return 1 + uniqueIndices.size; // title slide + one per award index
}, [awards]);

const handlePresentationStateChange = useCallback(
(slideIndex: number, stepIndex: number) => {
updateAudienceDisplaySetting({
variables: {
divisionId: currentDivision.id,
display: 'awards',
settingKey: 'presentationState',
settingValue: { slideIndex, stepIndex }
}
});
},
[updateAudienceDisplaySetting, currentDivision.id]
);

return (
<Stack spacing={2} height="100%">
<div className="flex-1 bg-black rounded-lg overflow-hidden">
<AwardsDisplay
ref={deckRef}
awards={awards}
awardWinnerSlideStyle={awardWinnerSlideStyle}
presentationState={presentationState}
/>
</div>
<PresentationController
deckRef={deckRef}
onPresentationStateChange={handlePresentationStateChange}
totalSlides={totalSlides}
/>
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import React, { useState, useEffect } from 'react';
import { DeckRef } from '@lems/presentations';
import { Button } from '@mui/material';
import {
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
SkipNext as SkipNextIcon,
SkipPrevious as SkipPreviousIcon,
Home as HomeIcon,
GetApp as GetAppIcon
} from '@mui/icons-material';

interface PresentationControllerProps {
deckRef: React.RefObject<DeckRef | null>;
onPresentationStateChange: (slideIndex: number, stepIndex: number) => void;
totalSlides?: number;
}

export const PresentationController: React.FC<PresentationControllerProps> = ({
deckRef,
onPresentationStateChange,
totalSlides = 0
}) => {
const [slideInfo, setSlideInfo] = useState({ slideIndex: 0, stepIndex: 0 });

useEffect(() => {
// Update slide info whenever ref changes or totalSlides changes
if (deckRef.current?.activeView) {
setSlideInfo({
slideIndex: deckRef.current.activeView.slideIndex,
stepIndex: deckRef.current.activeView.stepIndex
});
}
}, [deckRef, totalSlides]);

const getCurrentState = () => ({
slideCount: totalSlides,
slideIndex: slideInfo.slideIndex,
stepIndex: slideInfo.stepIndex
});

const current = getCurrentState();

const updateSlideInfo = () => {
if (deckRef.current?.activeView) {
setSlideInfo({
slideIndex: deckRef.current.activeView.slideIndex,
stepIndex: deckRef.current.activeView.stepIndex
});
}
};

const handlePreviousSlide = () => {
deckRef.current?.regressSlide();
setTimeout(() => {
updateSlideInfo();
if (deckRef.current?.activeView) {
onPresentationStateChange(
deckRef.current.activeView.slideIndex,
deckRef.current.activeView.stepIndex
);
}
}, 0);
};

const handleNextSlide = () => {
deckRef.current?.advanceSlide();
setTimeout(() => {
updateSlideInfo();
if (deckRef.current?.activeView) {
onPresentationStateChange(
deckRef.current.activeView.slideIndex,
deckRef.current.activeView.stepIndex
);
}
}, 0);
};

const handlePreviousStep = () => {
deckRef.current?.stepBackward();
setTimeout(() => {
updateSlideInfo();
if (deckRef.current?.activeView) {
onPresentationStateChange(
deckRef.current.activeView.slideIndex,
deckRef.current.activeView.stepIndex
);
}
}, 0);
};

const handleNextStep = () => {
deckRef.current?.stepForward();
setTimeout(() => {
updateSlideInfo();
if (deckRef.current?.activeView) {
onPresentationStateChange(
deckRef.current.activeView.slideIndex,
deckRef.current.activeView.stepIndex
);
}
}, 0);
};

const handleHome = () => {
deckRef.current?.skipTo({ slideIndex: 0, stepIndex: 0 });
setTimeout(() => {
setSlideInfo({ slideIndex: 0, stepIndex: 0 });
onPresentationStateChange(0, 0);
}, 0);
};

const handleEnd = () => {
const lastSlide = Math.max(0, current.slideCount - 1);
deckRef.current?.skipTo({ slideIndex: lastSlide, stepIndex: 0 });
setTimeout(() => {
setSlideInfo({ slideIndex: lastSlide, stepIndex: 0 });
onPresentationStateChange(lastSlide, 0);
}, 0);
};

return (
<div className="flex flex-col gap-4 p-4 bg-gray-900 rounded-lg border border-gray-700">
<div className="text-white text-sm font-mono">
<div>
Slide: {current.slideIndex + 1} / {current.slideCount}
</div>
<div>Step: {current.stepIndex}</div>
</div>

<div className="flex gap-2">
<Button
size="small"
variant="contained"
onClick={handleHome}
startIcon={<HomeIcon sx={{ fontSize: '16px' }} />}
title="Go to first slide"
>
First
</Button>
<Button
size="small"
variant="outlined"
onClick={handlePreviousSlide}
startIcon={<SkipPreviousIcon sx={{ fontSize: '16px' }} />}
title="Previous slide"
>
Prev Slide
</Button>
<Button
size="small"
variant="outlined"
onClick={handleNextSlide}
startIcon={<SkipNextIcon sx={{ fontSize: '16px' }} />}
title="Next slide"
>
Next Slide
</Button>
<Button
size="small"
variant="contained"
onClick={handleEnd}
startIcon={<GetAppIcon sx={{ fontSize: '16px' }} />}
title="Go to last slide"
>
Last
</Button>
</div>

<div className="flex gap-2">
<Button
size="small"
variant="outlined"
onClick={handlePreviousStep}
startIcon={<ChevronLeftIcon sx={{ fontSize: '16px' }} />}
title="Previous step"
>
Prev Step
</Button>
<Button
size="small"
variant="outlined"
onClick={handleNextStep}
startIcon={<ChevronRightIcon sx={{ fontSize: '16px' }} />}
title="Next step"
>
Next Step
</Button>
</div>
</div>
);
};
Loading