diff --git a/.env.example b/.env.example index ee36dab5..6369a1d8 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,12 @@ JWT_SECRET="your-jwt-secret-minimum-32-characters-long" # Get your JWT token from https://app.pinata.cloud/ PINATA_JWT="your-pinata-jwt-token" +# Optional: dedicated Pinata IPFS gateway origin (e.g. https://.mypinata.cloud). +# IPFS reads resolve through /api/ipfs/resolve, which tries this gateway first +# (our pinned content) before falling back to public gateways, and new uploads +# return a URL on this gateway instead of the frequently-504ing public ipfs.io. +# NEXT_PUBLIC_PINATA_GATEWAY_URL="https://your-gateway.mypinata.cloud" + # GitHub Personal Access Token # Create one at https://github.com/settings/tokens # GITHUB_TOKEN="your-github-token" (Optional - for GitHub issue creation) diff --git a/src/components/pages/wallet/governance/ballot/BallotCsv.tsx b/src/components/pages/wallet/governance/ballot/BallotCsv.tsx new file mode 100644 index 00000000..535f045c --- /dev/null +++ b/src/components/pages/wallet/governance/ballot/BallotCsv.tsx @@ -0,0 +1,235 @@ +import { useCallback } from "react"; +import Papa from "papaparse"; +import { useDropzone } from "react-dropzone"; +import { Download, Upload, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { toast } from "@/hooks/use-toast"; +import type { BallotType } from "./ballot"; + +/** + * CSV import/export for a ballot. The CSV columns are: + * proposal_id, title, vote, comment, anchor_url, anchor_hash + * + * Export uses Papa.unparse so commas / newlines inside rationale comments are + * quoted correctly. Import merges by `proposal_id`: existing proposals are + * updated in place, unseen ones are appended — proposals already in the ballot + * but absent from the CSV are preserved. For an existing proposal a blank CSV + * cell leaves that field unchanged (including the vote), so a CSV that only + * fills in, say, anchor_url won't reset votes. Newly-appended proposals default + * to an Abstain vote when their vote cell is blank. + */ + +const CSV_HEADERS = [ + "proposal_id", + "title", + "vote", + "comment", + "anchor_url", + "anchor_hash", +] as const; + +function normalizeVote(value: string | undefined): "Yes" | "No" | "Abstain" { + const s = (value ?? "").trim().toLowerCase(); + if (s === "yes" || s === "y") return "Yes"; + if (s === "no" || s === "n") return "No"; + return "Abstain"; +} + +function sanitizeFilename(name: string): string { + const cleaned = name.replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase(); + return cleaned || "ballot"; +} + +export default function BallotCsv({ + ballot, + ballotId, + onImported, +}: { + ballot: BallotType; + ballotId: string; + onImported?: () => void | Promise; +}) { + const updateBallot = api.ballot.updateBallot.useMutation(); + + const handleExport = useCallback(() => { + const rows = ballot.items.map((proposalId, i) => ({ + proposal_id: proposalId ?? "", + title: ballot.itemDescriptions?.[i] ?? "", + vote: ballot.choices?.[i] ?? "Abstain", + comment: ballot.rationaleComments?.[i] ?? "", + anchor_url: ballot.anchorUrls?.[i] ?? "", + anchor_hash: ballot.anchorHashes?.[i] ?? "", + })); + const csv = Papa.unparse({ fields: [...CSV_HEADERS], data: rows }); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute("download", `${sanitizeFilename(ballot.description ?? "ballot")}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, [ballot]); + + const onDrop = useCallback( + (files: File[]) => { + const file = files[0]; + if (!file) return; + Papa.parse>(file, { + header: true, + skipEmptyLines: true, + transformHeader: (h) => h.trim().toLowerCase(), + complete: (results) => { + void (async () => { + try { + const items = [...ballot.items]; + const itemDescriptions = [...(ballot.itemDescriptions ?? [])]; + const choices = [...(ballot.choices ?? [])]; + const anchorUrls = [...(ballot.anchorUrls ?? [])]; + const anchorHashes = [...(ballot.anchorHashes ?? [])]; + const rationaleComments = [...(ballot.rationaleComments ?? [])]; + + let added = 0; + let updated = 0; + let skipped = 0; + + for (const row of results.data) { + const proposalId = (row.proposal_id ?? row.proposalid ?? "").trim(); + if (!proposalId) { + skipped++; + continue; + } + const title = (row.title ?? "").trim(); + const voteRaw = (row.vote ?? "").trim(); + const comment = (row.comment ?? "").trim(); + const anchorUrl = (row.anchor_url ?? row.anchorurl ?? "").trim(); + const anchorHash = (row.anchor_hash ?? row.anchorhash ?? "").trim(); + + const idx = items.indexOf(proposalId); + if (idx >= 0) { + // Update in place; a blank cell leaves the existing value intact, + // including the vote (so anchor-only CSVs don't reset choices). + if (title) itemDescriptions[idx] = title; + if (voteRaw) choices[idx] = normalizeVote(voteRaw); + if (comment) rationaleComments[idx] = comment; + if (anchorUrl) anchorUrls[idx] = anchorUrl; + if (anchorHash) anchorHashes[idx] = anchorHash; + updated++; + } else { + // New proposal: a blank vote cell defaults to Abstain. + items.push(proposalId); + itemDescriptions.push(title); + choices.push(normalizeVote(voteRaw)); + rationaleComments.push(comment); + anchorUrls.push(anchorUrl); + anchorHashes.push(anchorHash); + added++; + } + } + + if (added === 0 && updated === 0) { + toast({ + title: "Nothing imported", + description: "No rows with a proposal_id were found. Check the CSV header.", + variant: "destructive", + }); + return; + } + + await updateBallot.mutateAsync({ + ballotId, + items, + itemDescriptions, + choices, + anchorUrls, + anchorHashes, + rationaleComments, + type: ballot.type, + }); + await onImported?.(); + toast({ + title: "Ballot imported", + description: `${added} added, ${updated} updated${skipped ? `, ${skipped} skipped` : ""}.`, + }); + } catch (error) { + toast({ + title: "Import failed", + description: error instanceof Error ? error.message : "Could not import CSV.", + variant: "destructive", + }); + } + })(); + }, + error: (error) => { + toast({ + title: "Parse error", + description: error.message, + variant: "destructive", + }); + }, + }); + }, + [ballot, ballotId, updateBallot, onImported], + ); + + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + onDrop, + accept: { "text/csv": [".csv"] }, + multiple: false, + noClick: true, + noKeyboard: true, + }); + + return ( +
+
+

+ Import / export ballot as CSV +

+
+ + +
+
+
+ + + {isDragActive + ? "Drop the CSV here…" + : "Drag & drop a CSV, or use Import. Columns: proposal_id, title, vote, comment, anchor_url, anchor_hash"} + +
+
+ ); +} diff --git a/src/components/pages/wallet/governance/ballot/ballot.tsx b/src/components/pages/wallet/governance/ballot/ballot.tsx index fe148eb9..9cfe76bb 100644 --- a/src/components/pages/wallet/governance/ballot/ballot.tsx +++ b/src/components/pages/wallet/governance/ballot/ballot.tsx @@ -36,6 +36,8 @@ import { hashDrepAnchor } from "@meshsdk/core"; import { Textarea } from "@/components/ui/textarea"; import { Input } from "@/components/ui/input"; import { parseProposalId } from "@/lib/governance"; +import { fetchIpfsJson } from "@/lib/ipfs"; +import BallotCsv from "./BallotCsv"; const GovAction = 1; @@ -880,12 +882,26 @@ export default function BallotCard({ + + {/* CSV import / export */} + getBallots.refetch()} + /> ) : (

No proposals in this ballot

-

Add proposals to start voting

+

Add proposals manually or import a CSV to start voting

+
+ getBallots.refetch()} + /> +