Repository navigation
Dev #585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Dev #585
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
594 changes: 594 additions & 0 deletions
594
sites/mainweb/app/(portal)/admin/analytics/AnalyticsPage.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
302 changes: 302 additions & 0 deletions
302
sites/mainweb/app/(portal)/admin/initiatives/AdminInitiativesPage.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
listBans()starts beforeawait all, but nothing catches its rejection untilreads()returns. If the bans query fails whilelistAll()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 startlistBans()after the wait, so database errors keep the intended client-side fallback.