Skip to content
Merged

Dev #585

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
594 changes: 594 additions & 0 deletions sites/mainweb/app/(portal)/admin/analytics/AnalyticsPage.tsx

Large diffs are not rendered by default.

600 changes: 11 additions & 589 deletions sites/mainweb/app/(portal)/admin/analytics/page.tsx

Large diffs are not rendered by default.

25 changes: 24 additions & 1 deletion sites/mainweb/app/(portal)/admin/hackathons/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PrefetchedPage } from "@/components/portal/PrefetchedPage";
import { decodeHackathonParam } from "@/lib/hackathon-slug";
import type { Prefetch } from "@/lib/portal-bootstrap.server";
import AdminHackathonDashboard from "./AdminHackathonPage";

// The Applications tab is what the dashboard opens on; a link to another tab
Expand All @@ -20,7 +21,8 @@ export default async function Page({
const found = await hackathon.catch(() => null);
if (!found) return [["hackathon.getById", { id }, hackathon]];
const hackathonId = found.id;
return [
const { status } = found;
const entries: Prefetch[] = [
["hackathon.getById", { id }, hackathon],
["hackathon.getById", { id: hackathonId }, hackathon],
[
Expand All @@ -38,6 +40,27 @@ export default async function Page({
c.hackathon.analytics({ hackathonId }),
],
];
// The Applications tab shows the interest list for an announced
// edition and the acceptance waves for any other.
if (status !== "announced") {
entries.push([
"hackathon.waveStatus",
{ hackathonId },
c.hackathon.waveStatus({ hackathonId }),
]);
}
if (
status === "announced" ||
status === "open" ||
status === "in_progress"
) {
entries.push([
"hackathon.registrationOpenEmailStatus",
{ hackathonId },
c.hackathon.registrationOpenEmailStatus({ hackathonId }),
]);
}
return entries;
}}
>
<AdminHackathonDashboard />
Expand Down
22 changes: 18 additions & 4 deletions sites/mainweb/app/(portal)/admin/hackathons/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { PrefetchedPage } from "@/components/portal/PrefetchedPage";
import type { Prefetch } from "@/lib/portal-bootstrap.server";
import AdminHackathonsPage from "./AdminHackathonsPage";

export default function Page() {
return (
<PrefetchedPage
reads={(c) => [
["hackathon.listAll", undefined, c.hackathon.listAll()],
["hackathon.listBans", undefined, c.hackathon.listBans()],
]}
reads={async (c) => {
const all = c.hackathon.listAll();
const entries: Prefetch[] = [
["hackathon.listAll", undefined, all],
["hackathon.listBans", undefined, c.hackathon.listBans()],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Query errors escape the fallback

listBans() starts before await all, but nothing catches its rejection until reads() returns. If the bans query fails while listAll() is still waiting on the database, the server gets an unhandled rejection instead of skipping the failed prefetch. This can disrupt the admin page or its server worker. Attach a rejection handler immediately, or start listBans() after the wait, so database errors keep the intended client-side fallback.

];
// Each card reads its hackathon's events.
const hackathons = await all.catch(() => []);
for (const { id: hackathonId } of hackathons) {
entries.push([
"hackathon.getEvents",
{ hackathonId },
c.hackathon.getEvents({ hackathonId }),
]);
}
return entries;
}}
>
<AdminHackathonsPage />
</PrefetchedPage>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
"use client";

import { useState } from "react";
import { useSession } from "next-auth/react";
import { LoadingScreen } from "@/components/portal/LoadingScreen";
import { trpc } from "@/lib/trpc";
import { READ_ONLY_TITLE, useReadOnly } from "@/lib/use-portal-context";
import type { RouterOutputs } from "@query/api";
import {
body,
btnDanger,
btnInk,
btnPrimary,
btnSecondary,
fieldLabel,
input,
itemTitle,
meta,
page,
pageDek,
sectionTitle,
} from "@/components/portal/ui";

const adminTitle =
"font-[family-name:var(--font-display)] text-[32px] md:text-[40px] font-semibold leading-[1.05] tracking-[-0.02em] text-[var(--text-primary)] text-balance";

/**
* Who runs club initiatives.
*
* Granting takes a user id rather than an email search: this reuses the
* attendees list every officer already works from, and a leader has to have
* signed in at least once to have an id at all.
*/
function ProposalRow({
proposal,
}: {
proposal: RouterOutputs["initiative"]["listProposals"][number];
}) {
const utils = trpc.useUtils();
const readOnly = useReadOnly();
const [note, setNote] = useState("");
const [declining, setDeclining] = useState(false);

const review = trpc.initiative.reviewProposal.useMutation({
onSuccess: async () => {
await Promise.all([
utils.initiative.listProposals.invalidate(),
// Approving mints a project leader, so that list moves too.
utils.initiative.listLeaders.invalidate(),
]);
},
});

return (
<div className="py-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className={itemTitle}>{proposal.title}</h3>
<p className={`${meta} mt-0.5 break-all`}>
{proposal.proposerName ?? proposal.proposerEmail} ·{" "}
{proposal.proposerEmail}
</p>
{proposal.summary && (
<p className="mt-3 text-[15px] text-[var(--text-secondary)]">
{proposal.summary}
</p>
)}
{proposal.description && (
<p className={`${body} mt-2 whitespace-pre-line`}>
{proposal.description}
</p>
)}
<p className={`${meta} mt-3`}>
{proposal.commitment ?? "No commitment given"} ·{" "}
{proposal.maxMembers === null
? "no team cap"
: `cap ${proposal.maxMembers}`}
</p>
</div>

<div className="flex shrink-0 flex-wrap items-center gap-2">
<button
type="button"
disabled={readOnly || review.isPending}
title={readOnly ? READ_ONLY_TITLE : undefined}
onClick={() =>
review.mutate({ id: proposal.id, decision: "approve" })
}
className={btnInk}
>
Approve
</button>
<button
type="button"
disabled={readOnly || review.isPending}
title={readOnly ? READ_ONLY_TITLE : undefined}
onClick={() => setDeclining((prev) => !prev)}
className={btnSecondary}
>
Decline
</button>
</div>
</div>

{/* A decline without a reason is the thing a member can do nothing with,
so the note is asked for at the moment of declining. */}
{declining && (
<div className="mt-4 border-t border-[var(--border-subtle)] pt-4">
<label htmlFor={`note-${proposal.id}`} className={fieldLabel}>
Why (shown to them)
</label>
<textarea
id={`note-${proposal.id}`}
rows={2}
maxLength={1000}
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Too close to an existing project, needs a clearer scope, …"
className={`${input} min-h-11`}
/>
<button
type="button"
disabled={readOnly || review.isPending}
title={readOnly ? READ_ONLY_TITLE : undefined}
onClick={() =>
review.mutate({
id: proposal.id,
decision: "decline",
note: note.trim() || undefined,
})
}
className={`${btnDanger} mt-3`}
>
Confirm decline
</button>
</div>
)}

{review.error && (
<p role="alert" className="mt-3 text-[13px] text-[var(--danger)]">
{review.error.message}
</p>
)}
</div>
);
}

export default function AdminInitiativesPage() {
const { data: session, status } = useSession();
const utils = trpc.useUtils();
const readOnly = useReadOnly();
const [userId, setUserId] = useState("");

const leaders = trpc.initiative.listLeaders.useQuery(undefined, {
enabled: !!session,
});
const proposals = trpc.initiative.listProposals.useQuery(undefined, {
enabled: !!session,
});

const setLeader = trpc.initiative.setLeader.useMutation({
onSuccess: async () => {
setUserId("");
await utils.initiative.listLeaders.invalidate();
},
});

if (status === "loading" || leaders.isPending) return <LoadingScreen />;

if (leaders.error) {
return (
<div className={page}>
<h1 className={adminTitle}>Project leaders</h1>
<p role="alert" className="mt-3 text-[15px] text-[var(--danger)]">
{leaders.error.message}
</p>
</div>
);
}

const rows = leaders.data ?? [];

return (
<div className={page}>
<h1 className={adminTitle}>Project leaders</h1>
<p className={pageDek}>
A project leader can post projects and pick who joins them. It grants
nothing else; admin screens stay admin-only.
</p>

<section className="mt-10 border-t border-[var(--border-subtle)] pt-6">
<h2 className={sectionTitle}>
Proposals waiting on you
{proposals.data && proposals.data.length > 0 ? (
<span className="ml-2 text-[var(--text-subtle)] tabular-nums">
{proposals.data.length}
</span>
) : null}
</h2>
{proposals.error ? (
<p role="alert" className="mt-3 text-[13px] text-[var(--danger)]">
{proposals.error.message}
</p>
) : (proposals.data ?? []).length > 0 ? (
<div className="mt-2 divide-y divide-[var(--border-subtle)]">
{(proposals.data ?? []).map((proposal) => (
<ProposalRow key={proposal.id} proposal={proposal} />
))}
</div>
) : (
<p className={`${body} mt-3`}>
Nothing waiting. Members pitch projects from their Projects page;
approving one makes them a project leader.
</p>
)}
</section>

<section className="mt-12 border-t border-[var(--border-subtle)] pt-6">
<h2 className={sectionTitle}>Project leaders</h2>

<form
className="mt-4 flex flex-col sm:flex-row sm:items-end gap-3"
onSubmit={(event) => {
event.preventDefault();
if (!userId.trim()) return;
setLeader.mutate({ userId: userId.trim(), isLeader: true });
}}
>
<div className="min-w-0 sm:flex-1">
<label htmlFor="leader-user-id" className={fieldLabel}>
Grant by user id
</label>
<input
id="leader-user-id"
value={userId}
onChange={(event) => setUserId(event.target.value)}
placeholder="User id from the attendees list"
className={`${input} min-h-11 font-mono`}
/>
</div>
<button
type="submit"
disabled={readOnly || setLeader.isPending}
title={readOnly ? READ_ONLY_TITLE : undefined}
className={btnPrimary}
>
{setLeader.isPending ? "Saving…" : "Make leader"}
</button>
</form>

{setLeader.error && (
<p role="alert" className="mt-3 text-[13px] text-[var(--danger)]">
{setLeader.error.message}
</p>
)}

{rows.length > 0 ? (
<div className="mt-8 border-t border-[var(--border-subtle)] divide-y divide-[var(--border-subtle)]">
{rows.map((leader) => (
<div
key={leader.id}
className="py-3 flex flex-wrap items-center justify-between gap-4"
>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-semibold text-[var(--text-primary)]">
{leader.name ?? leader.email}
{!leader.isActive && (
<span className="ml-2 text-[13px] font-normal text-[var(--text-subtle)]">
revoked
</span>
)}
</p>
<p className={`${meta} break-all`}>{leader.email}</p>
</div>

<button
type="button"
disabled={readOnly || setLeader.isPending}
title={readOnly ? READ_ONLY_TITLE : undefined}
onClick={() =>
setLeader.mutate({
userId: leader.userId,
isLeader: !leader.isActive,
})
}
className={leader.isActive ? btnDanger : btnSecondary}
>
{leader.isActive ? "Revoke" : "Restore"}
</button>
</div>
))}
</div>
) : (
<p className={`${body} mt-8`}>
No leaders yet. Grant the role above and it takes effect on their
next request.
</p>
)}
</section>
</div>
);
}
Loading
Loading