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
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,21 @@ Backfill script: `backend-ohack.dev/scripts/backfill_devpost_funnel.py` — dry-

Front-of-house: `HackathonResults` accepts a `fullResultsHref` prop. On `/hack/[event_id]` it points to `/hack/[event_id]/results` so users can jump to the deeper page. The /results page renders the same `HackathonResults` widget at top + `HackathonFunnel` below.

## Team Demo Videos
Per-team `demo_video_url` (string) + companion `demo_video_url_submitted` (ISO timestamp, set on first save). Stored on the Firestore `teams` doc, mirrors the `devpost_link` shape. Allowed providers: YouTube, Vimeo, Loom, Google Drive (same set the `VideoDisplay` component handles).
- **Backend:** field in `edit_team()` allowlist (`api/teams/teams_service.py`). Hacker self-serve via `POST /api/team/<teamid>/demo-video` (mirrors `/devpost`). Admin uses the existing `PATCH /api/team/edit` (gated on `volunteer.admin`).
- **Public display:** `TeamList.js` shows a `<LiteVideoThumbnail>` per team card (CWV-safe — single lazy `<img>` of the YouTube hqdefault.jpg, NOT an iframe per card). Click → one page-level `<Dialog>` with `<VideoDisplay>`. Per-card iframes were rejected because 30+ embeds = ~45MB and trashes LCP/CLS.
- **Winners (`HackathonResults` on `/hack/[id]/results`):** inline `<VideoDisplay>` embed — only 3-5 cards so direct iframe is fine. Iframe has `loading="lazy"`.
- **Hacker self-serve:** `TeamCreation/TeamStatusPanel.js` has its own "Demo Video" section beside the DevPost section with live preview via `LiteVideoThumbnail`. Validates URL is one of the 4 supported providers client-side.
- **`LiteVideoThumbnail`** (`src/components/VideoDisplay/LiteVideoThumbnail.js`): the lite-embed component. Renders YouTube hqdefault thumb when the URL is YouTube; otherwise a generic dark "▶ Watch demo" tile (don't fetch Vimeo oEmbed per render — kills CWV). Always wraps a `<button>` with `loading="lazy" decoding="async"` + explicit `width`/`height` (CLAUDE.md CWV rule).

## Admin Teams (`/admin/teams?event_id=...`)
File: `src/components/admin/TeamManagement.js` (~2900 lines, hosted in `src/pages/admin/teams/index.js`). Three concerns added together (deliberately scoped — no full redesign):
- **Demo Video column + inline-edit Popover** (`TeamFieldPopover`): table cell shows a 96×54 thumbnail if set, "+ Add" button if missing. Click → Popover with `TextField` + live `LiteVideoThumbnail` preview + Save/Cancel/Clear. Optimistic update: `handleQuickPatch` PATCHes the partial and merges into local `teams` state — no full refetch.
- **`patchTeam(partial)` helper**: extracted from `handleSaveTeam`. Always include `id` in the partial. Both the full edit Dialog and `TeamFieldPopover` use it. If you add another quick-edit field, hang it off this same helper + the Popover (parameterize `field`/`label`/`placeholder`/`validate`/`previewKind`).
- **Filter chips above the table** (state: `activeFilter`): `All` / `Winning` / `In review` / `Active` / `Missing DevPost` / `Missing Video`. Pure client-side — extends the existing `filteredTeams` useEffect. Filter resets `page` to 0 so the user lands on results.
- The full edit Dialog's Team Details tab also has the Demo Video URL TextField (next to DevPost), with the same `validateDemoVideoUrl` helper and a `LiteVideoThumbnail` preview underneath.

## Local Landing Pages

### Arizona Hackathons (`/hackathons/arizona`)
Expand Down
10 changes: 10 additions & 0 deletions src/components/Hackathon/HackathonResults.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import PendingIcon from '@mui/icons-material/HourglassTop';
import { useAuthInfo } from '@propelauth/react';
import { WINNING_STATUSES, isWinningStatus, getWinningStatus } from '../../constants/teamStatus';
import TeamMember from './TeamMember';
import VideoDisplay from '../VideoDisplay/VideoDisplay';

const RANK_STYLES = {
1: { gradient: 'linear-gradient(135deg, #FFD700 0%, #FFA000 100%)', emoji: '\uD83E\uDD47', border: '#FFD700' },
Expand Down Expand Up @@ -316,6 +317,15 @@ const HackathonResults = ({ teams, nonprofitMap, eventId, eventTitle, githubOrg,
</Box>
)}

{team.demo_video_url && (
<Box sx={{ mb: 2 }}>
<VideoDisplay
url={team.demo_video_url}
title={`${team.name} demo`}
/>
</Box>
)}

<Box sx={{ mt: 'auto', display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{githubLink && (
<Chip
Expand Down
57 changes: 56 additions & 1 deletion src/components/Hackathon/TeamList.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,15 @@ import {
FaHandshake,
FaUsers,
FaShieldAlt,
FaTimes,
} from 'react-icons/fa';
import { useAuthInfo } from "@propelauth/react";
import MuiAlert from "@mui/material/Alert";
import { formatDistanceToNow, parseISO } from 'date-fns';
import { TEAM_STATUS_OPTIONS, getStatusOption, isJoiningDisabled } from '../../constants/teamStatus';
import { isHackathonExpired } from '../../lib/dateUtils';
import LiteVideoThumbnail from '../VideoDisplay/LiteVideoThumbnail';
import VideoDisplay from '../VideoDisplay/VideoDisplay';

// Helper function to check if team status prevents joining
const isJoiningDisabledByStatus = (status) => {
Expand Down Expand Up @@ -777,7 +780,7 @@ const GitHubStats = ({ githubUrl, teamMembers, accessToken, onStatsLoaded }) =>
};

// Team Card component - extracted for better organization
const TeamCard = ({ team, userProfile, isLoggedIn, onJoin, onLeave, loadingTeamId, isHackathonExpired, teamJoinEnabled, nonprofitMap, accessToken, onCopyGithubUsername }) => {
const TeamCard = ({ team, userProfile, isLoggedIn, onJoin, onLeave, loadingTeamId, isHackathonExpired, teamJoinEnabled, nonprofitMap, accessToken, onCopyGithubUsername, onPlayVideo }) => {
const hasGithubLinks = team?.github_links && team?.github_links.length > 0;
const [githubData, setGithubData] = useState(null);
const [showJoinModal, setShowJoinModal] = useState(false);
Expand Down Expand Up @@ -938,6 +941,17 @@ const TeamCard = ({ team, userProfile, isLoggedIn, onJoin, onLeave, loadingTeamI
)}
</Box>

{/* Demo Video */}
{team?.demo_video_url && (
<Box sx={{ mb: 1.5 }}>
<LiteVideoThumbnail
url={team.demo_video_url}
label={`Watch ${team?.name || 'team'} demo`}
onClick={() => onPlayVideo?.(team.demo_video_url, team?.name)}
/>
</Box>
)}

{/* DevPost Project */}
<Box sx={{ mb: 1 }}>
{team?.devpost_link ? (
Expand Down Expand Up @@ -1083,8 +1097,21 @@ const TeamList = ({ teams, event_id, id, endDate, eventTimezone, constraints = {
const [userProfile, setUserProfile] = useState(null);
const [nonprofitMap, setNonprofitMap] = useState({});
const [nonprofitsLoading, setNonprofitsLoading] = useState(false);
const [videoDialog, setVideoDialog] = useState({
open: false,
url: null,
teamName: null,
});
const { isLoggedIn, accessToken } = useAuthInfo();

const handlePlayVideo = useCallback((url, teamName) => {
setVideoDialog({ open: true, url, teamName: teamName || null });
}, []);

const handleCloseVideoDialog = useCallback(() => {
setVideoDialog((prev) => ({ ...prev, open: false }));
}, []);

// Check if team joining is enabled from constraints
const teamJoinEnabled = constraints.team_join_enabled !== false; // Default to true if not specified

Expand Down Expand Up @@ -1409,11 +1436,39 @@ const TeamList = ({ teams, event_id, id, endDate, eventTimezone, constraints = {
nonprofitMap={nonprofitMap}
accessToken={accessToken}
onCopyGithubUsername={handleCopyGithubUsername}
onPlayVideo={handlePlayVideo}
/>
</Grid>
))}
</Grid>

<Dialog
open={videoDialog.open}
onClose={handleCloseVideoDialog}
maxWidth="md"
fullWidth
aria-labelledby="team-demo-video-title"
>
<DialogTitle id="team-demo-video-title" sx={{ pr: 6 }}>
{videoDialog.teamName ? `${videoDialog.teamName} demo` : "Team demo"}
<IconButton
aria-label="Close demo video"
onClick={handleCloseVideoDialog}
sx={{ position: "absolute", right: 8, top: 8 }}
>
<FaTimes />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{videoDialog.url && (
<VideoDisplay
url={videoDialog.url}
title={videoDialog.teamName || "Team demo"}
/>
)}
</DialogContent>
</Dialog>

<Snackbar
open={snackbar.open}
autoHideDuration={6000}
Expand Down
167 changes: 166 additions & 1 deletion src/components/TeamCreation/TeamStatusPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import WorkspacePremiumIcon from '@mui/icons-material/WorkspacePremium';
import StarIcon from '@mui/icons-material/Star';
import Link from 'next/link';
import LiteVideoThumbnail from '../VideoDisplay/LiteVideoThumbnail';

const StyledPaper = styled(Paper)(({ theme }) => ({
padding: theme.spacing(3),
Expand Down Expand Up @@ -244,6 +245,8 @@ const TeamStatusPanel = ({ teams, loading, error, nonprofits, event, eventId, ac
const [selectedVideo, setSelectedVideo] = useState('');
const [devpostSubmissions, setDevpostSubmissions] = useState({});
const [devpostLoading, setDevpostLoading] = useState({});
const [demoVideoSubmissions, setDemoVideoSubmissions] = useState({});
const [demoVideoLoading, setDemoVideoLoading] = useState({});
const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' });

// Select a random waiting video on component mount
Expand Down Expand Up @@ -339,6 +342,74 @@ const TeamStatusPanel = ({ teams, loading, error, nonprofits, event, eventId, ac
setDevpostSubmissions(prev => ({ ...prev, [teamId]: value }));
};

// Demo video URL: accept YouTube, Vimeo, Loom, or Google Drive (matches VideoDisplay providers)
const isValidDemoVideoUrl = (url) => {
if (!url) return false;
const trimmed = url.trim();
return (
/youtube\.com\/.+v=[\w-]{11}/i.test(trimmed) ||
/youtu\.be\/[\w-]{11}/i.test(trimmed) ||
/vimeo\.com\/\d+/i.test(trimmed) ||
/loom\.com\/(share|embed)\/[a-zA-Z0-9]+/i.test(trimmed) ||
/drive\.google\.com\/file\/d\//i.test(trimmed)
);
};

const handleDemoVideoInputChange = (teamId, value) => {
setDemoVideoSubmissions(prev => ({ ...prev, [teamId]: value }));
};

const handleDemoVideoSubmit = async (teamId) => {
const url = (demoVideoSubmissions[teamId] || '').trim();
if (!url) {
setSnackbar({ open: true, message: 'Please enter a video URL', severity: 'error' });
return;
}
if (!isValidDemoVideoUrl(url)) {
setSnackbar({
open: true,
message: 'Please enter a YouTube, Vimeo, Loom, or Google Drive video URL.',
severity: 'error',
});
return;
}

setDemoVideoLoading(prev => ({ ...prev, [teamId]: true }));
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_SERVER_URL}/api/team/${teamId}/demo-video`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ demo_video_url: url }),
}
);
if (!response.ok) throw new Error('Failed to update demo video');

// Mirror DevPost handler: snackbar success; team prop is owned by parent.
// Locally update the input value to the saved URL so the preview reflects truth.
setDemoVideoSubmissions(prev => ({ ...prev, [teamId]: url }));

setSnackbar({
open: true,
message: 'Demo video saved! Judges and visitors will see it on the event page.',
severity: 'success',
});
} catch (error) {
console.error('Error updating demo video URL:', error);
setSnackbar({
open: true,
message: 'Failed to update demo video. Please try again.',
severity: 'error',
});
} finally {
setDemoVideoLoading(prev => ({ ...prev, [teamId]: false }));
}
};

// Handle loading state with better UX
if (loading) {
return (
Expand Down Expand Up @@ -1340,7 +1411,101 @@ const TeamStatusPanel = ({ teams, loading, error, nonprofits, event, eventId, ac
Judging Criteria
</Button>
</Box>
</Box>
</Box>
</CardContent>
</Card>
</Box>

{/* Demo Video Section */}
<Box sx={{ mb: 3 }}>
<Typography
variant="subtitle1"
fontWeight="bold"
gutterBottom
sx={{ display: "flex", alignItems: "center" }}
>
<YouTubeIcon fontSize="small" sx={{ mr: 1 }} /> Demo Video
</Typography>
<Card
variant="outlined"
sx={{ borderLeft: "4px solid #c4302b", borderRadius: 1 }}
>
<CardContent>
{team.demo_video_url ? (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<CheckIcon sx={{ color: 'success.main', mr: 1 }} />
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>
Demo Video Linked
</Typography>
</Box>
) : (
<Typography
variant="body1"
paragraph
sx={{ color: "text.secondary", fontSize: "1.05rem" }}
>
🎬 <strong>Share Your Demo Video:</strong> Paste a public YouTube (or Vimeo / Loom / Google Drive) link so judges and visitors can watch your demo right from the event page.
</Typography>
)}

<TextField
fullWidth
label="Demo Video URL"
placeholder="https://youtu.be/..."
value={demoVideoSubmissions[team.id] ?? team.demo_video_url ?? ''}
onChange={(e) => handleDemoVideoInputChange(team.id, e.target.value)}
error={
!!demoVideoSubmissions[team.id] &&
!isValidDemoVideoUrl(demoVideoSubmissions[team.id])
}
helperText={
demoVideoSubmissions[team.id] && !isValidDemoVideoUrl(demoVideoSubmissions[team.id])
? "Enter a YouTube, Vimeo, Loom, or Google Drive URL."
: team.demo_video_url && !demoVideoSubmissions[team.id]
? "Your current demo video is shown above."
: "Public link only — viewers will not need to sign in."
}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<YouTubeIcon color="action" />
</InputAdornment>
),
}}
sx={{ mb: 2 }}
/>

{(() => {
const previewUrl =
(demoVideoSubmissions[team.id] && isValidDemoVideoUrl(demoVideoSubmissions[team.id])
? demoVideoSubmissions[team.id]
: team.demo_video_url) || null;
return previewUrl ? (
<Box sx={{ mb: 2, maxWidth: 360 }}>
<LiteVideoThumbnail
url={previewUrl}
label="Preview"
onClick={() => window.open(previewUrl, '_blank', 'noopener,noreferrer')}
/>
</Box>
) : null;
})()}

<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button
variant="contained"
color="primary"
onClick={() => handleDemoVideoSubmit(team.id)}
disabled={
demoVideoLoading[team.id] ||
!demoVideoSubmissions[team.id] ||
!isValidDemoVideoUrl(demoVideoSubmissions[team.id])
}
startIcon={demoVideoLoading[team.id] ? <CircularProgress size={16} /> : <SendIcon />}
>
{demoVideoLoading[team.id] ? 'Saving...' : (team.demo_video_url ? 'Update Video' : 'Save Video')}
</Button>
</Box>
</CardContent>
</Card>
</Box>
Expand Down
Loading