Skip to content
Open
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
94 changes: 94 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"convex-helpers": "^0.1.120",
"date-fns": "^3.3.1",
"highlight.js": "11.11.1",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"lucide-react": "^0.488.0",
"qrcode.react": "^4.2.0",
Expand Down
8 changes: 4 additions & 4 deletions src/components/admin/AdminDocs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@ The judging system lets you run scored competitions (hackathons, demo days, cont
- **Criteria**: the questions judges score, each on a 1 to 5 or 1 to 10 scale (set per group in Settings), with optional weights.
- **Submissions**: apps pulled into the group manually, by tag sync, or through a custom submission page.
- **Judges**: humans who sign in with a name (and optional password), or AI agents using API keys.
- **Results**: live score dashboards, public results pages, CSV exports, and judge tracking.
- **Results**: live score dashboards, public results pages, submission downloads, and judge tracking.
- **AI judge**: an optional automated reviewer that reads each submission (including its GitHub repo) and scores it against a fixed rubric.

**Where things live in this dashboard:**

- The **Judging** tab lists all groups with actions for settings, criteria, results, tracking, AI results, exports, and deletion.
- The **Judging** tab lists all groups. Open a group to manage it; submission downloads are in **View submissions**.
- **Judge Tracking** opens from a group row and shows per-judge activity with score editing.
- The **Access** tab (full admins only) delegates judging management to organizers without making them full admins.`,
},
Expand Down Expand Up @@ -498,9 +498,9 @@ For the full external facing guide (login, passwords, criteria, notes, filters,
- **Public results page**: \`/judging/your-slug/results\`. Public when the group marks results public, otherwise protected by the results password.
- **Admin results**: the same dashboard inside the admin (visible when results are not public), showing rankings, weighted totals, per-criterion averages, and per-judge detail.

## CSV exports
## Submission downloads

- **Export CSV** on a group row downloads every submission with tags, links, team info, submitter, and vote counts.
- The **Download** dropdown in a group's View submissions toolbar exports the same submission details as the existing CSV in CSV, JSON, or Markdown format. Markdown downloads are packaged as a ZIP with one file per submission.
- Judge Tracking has its own export with per-judge scores.

## Judge Tracking
Expand Down
129 changes: 3 additions & 126 deletions src/components/admin/judging/GroupSubmissionsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useConvex, useMutation, useQuery } from "convex/react";
import { Check, Download, Loader2, Plus, RefreshCw, Search, X } from "lucide-react";
import { useMutation, useQuery } from "convex/react";
import { Check, Loader2, Plus, RefreshCw, Search, X } from "lucide-react";
import { api } from "../../../../convex/_generated/api";
import { Id } from "../../../../convex/_generated/dataModel";
import { Input } from "../../ui/input";
Expand All @@ -15,18 +15,9 @@ import {
useSaveState,
} from "./groupSection";

// Escape a CSV cell value (handles commas, quotes, newlines)
function escapeCsv(value: string): string {
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}

// Submission sources: multi-tag auto-include with optional date range,
// backfill sync actions, and CSV export of everything in the group.
// plus backfill sync actions for existing matching submissions.
export function GroupSubmissionsSection({ group }: { group: GroupDetails }) {
const convex = useConvex();
const updateGroup = useMutation(api.judgingGroups.updateGroup);
const syncAutoIncludeSubmissions = useMutation(
api.judgingGroupSubmissions.syncAutoIncludeSubmissions,
Expand Down Expand Up @@ -55,8 +46,6 @@ export function GroupSubmissionsSection({ group }: { group: GroupDetails }) {
const [addMessage, setAddMessage] = useState<string | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportMessage, setExportMessage] = useState<string | null>(null);

// Live story search for the manual add card (skipped until 2+ characters)
const trimmedStorySearch = storySearch.trim();
Expand Down Expand Up @@ -140,95 +129,6 @@ export function GroupSubmissionsSection({ group }: { group: GroupDetails }) {
}
};

// Fetch submissions on demand and download as CSV (same columns as before)
const handleExportCsv = async () => {
setExportMessage(null);
setIsExporting(true);
try {
const rows = await convex.query(
api.judgingGroupSubmissions.exportGroupSubmissions,
{ groupId: group._id },
);
if (!rows || rows.length === 0) {
setExportMessage("This judging group has no submissions to export.");
return;
}
const headers = [
"App Title",
"App/Project Tagline",
"Description",
"App Website Link",
"Video Demo URL",
"GitHub",
"LinkedIn",
"Twitter/X",
"Chef Show URL",
"Chef App URL",
"Tags",
"Team Name",
"Team Member Count",
"Team Members",
"Submitter Name",
"Email",
"Slug",
"Votes",
];
const csvLines = [headers.map(escapeCsv).join(",")];
for (const row of rows) {
csvLines.push(
[
row.title,
row.tagline,
row.longDescription || "",
row.url,
row.videoUrl || "",
row.githubUrl || "",
row.linkedinUrl || "",
row.twitterUrl || "",
row.chefShowUrl || "",
row.chefAppUrl || "",
row.tags,
row.teamName || "",
row.teamMemberCount !== undefined
? String(row.teamMemberCount)
: "",
row.teamMembers,
row.submitterName || "",
row.email || "",
row.slug,
String(row.votes),
]
.map(escapeCsv)
.join(","),
);
}
const blob = new Blob([csvLines.join("\n")], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const timestamp = new Date().toISOString().split("T")[0];
link.setAttribute("href", url);
link.setAttribute(
"download",
`judging-${group.name.toLowerCase().replace(/\s+/g, "-")}-submissions-${timestamp}.csv`,
);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err) {
setExportMessage(
err instanceof Error
? err.message
: "Could not export submissions. Please try again.",
);
} finally {
setIsExporting(false);
}
};

return (
<div className="space-y-4">
<SectionCard
Expand Down Expand Up @@ -468,29 +368,6 @@ export function GroupSubmissionsSection({ group }: { group: GroupDetails }) {
</div>
</SectionCard>

<SectionCard
title="Export"
description="Download all submissions in this group as a CSV, including custom form fields."
>
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
onClick={() => void handleExportCsv()}
disabled={isExporting}
className="inline-flex items-center gap-1.5 px-3.5 py-1.5 text-[13px] font-medium rounded-md border border-hairline text-copy hover:bg-surface-hover transition-colors disabled:opacity-50"
>
{isExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Download className="w-3.5 h-3.5" />
)}
{isExporting ? "Exporting..." : "Export CSV"}
</button>
{exportMessage && (
<span className="text-[13px] text-copy">{exportMessage}</span>
)}
</div>
</SectionCard>
</div>
);
}
Loading