mirror of
https://github.com/siv-org/siv.git
synced 2026-01-09 10:27:57 -05:00
Merge branch 'main' into registration-link
This commit is contained in:
24
.env.local.TEMPLATE
Normal file
24
.env.local.TEMPLATE
Normal file
@@ -0,0 +1,24 @@
|
||||
# To use /admin interface
|
||||
JWT_SECRET =
|
||||
ADMIN_EMAIL =
|
||||
|
||||
# Pushover
|
||||
PUSHOVER_APP_TOKEN =
|
||||
PUSHOVER_USER_KEY =
|
||||
|
||||
# Firebase
|
||||
FIREBASE_CLIENT_EMAIL =
|
||||
FIREBASE_PRIVATE_KEY =
|
||||
FIREBASE_PROJECT_ID =
|
||||
|
||||
# Mailgun
|
||||
MAILGUN_API_KEY =
|
||||
|
||||
# Pusher
|
||||
PUSHER_KEY =
|
||||
PUSHER_SECRET =
|
||||
|
||||
# Supabase
|
||||
SUPABASE_ADMIN_KEY =
|
||||
SUPABASE_DB_URL =
|
||||
SUPABASE_JWT_SECRET =
|
||||
@@ -23,10 +23,12 @@ module.exports = {
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0, // Verbose
|
||||
'@typescript-eslint/no-empty-function': 0, // unnecessary
|
||||
'@typescript-eslint/no-unused-vars': 1, // hint not error
|
||||
'react/jsx-sort-props': [2, { callbacksLast: true, shorthandFirst: true }], // style
|
||||
'react/no-unknown-property': [2, { ignore: ['jsx', 'global'] }], // inserted by next's styled-jsx
|
||||
'react/react-in-jsx-scope': 0, // Handled by Next.js
|
||||
'sort-destructure-keys/sort-destructure-keys': 2, // style
|
||||
'sort-keys-fix/sort-keys-fix': 2, // style
|
||||
'sort-destructure-keys/sort-destructure-keys': 1, // style
|
||||
'sort-keys-fix/sort-keys-fix': 1, // style
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,3 +14,5 @@ cypress.env.json
|
||||
TODO.md
|
||||
*.log
|
||||
*.todo
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -4,4 +4,7 @@ module.exports = {
|
||||
},
|
||||
extends: ['plugin:cypress/recommended'],
|
||||
plugins: ['cypress'],
|
||||
rules: {
|
||||
'cypress/unsafe-to-chain-command': 0,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ describe('The webapp should render', () => {
|
||||
})
|
||||
|
||||
it('/protocol', () => {
|
||||
cy.visit('/protocol').contains('Secure Internet Voting (SIV) Protocol Overview')
|
||||
cy.visit('/protocol').contains('Secure Internet Voting Protocol')
|
||||
})
|
||||
|
||||
it('/about', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Beginning of an algorithm to calculate max burst.
|
||||
// Goal is to use this to see the highest # of votes submitted in a 60 window, 10 second window, 1 second etc.
|
||||
|
||||
function calculateMaxBurst(timestamps: number[], windowSize: number): number {
|
||||
export function calculateMaxBurst(timestamps: number[], windowSize: number): number {
|
||||
// Initialize variables
|
||||
let startIdx = 0
|
||||
let endIdx = 0
|
||||
|
||||
1
next-env.d.ts
vendored
1
next-env.d.ts
vendored
@@ -1,5 +1,4 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
|
||||
@@ -8,6 +8,15 @@ const withTM = require('next-transpile-modules')(['lodash-es'])
|
||||
**/
|
||||
const nextConfig = withMDX(
|
||||
withTM({
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
destination: 'https://docs.siv.org/research-in-progress/ukraine',
|
||||
permanent: true,
|
||||
source: '/ukraine',
|
||||
},
|
||||
]
|
||||
},
|
||||
typescript: {
|
||||
// !! WARN !!
|
||||
// Dangerously allow production builds to successfully complete even if
|
||||
|
||||
42
package.json
42
package.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "siv-demo",
|
||||
"name": "siv-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -12,7 +12,8 @@
|
||||
"lint": "eslint . --ext ts --ext tsx --ext js",
|
||||
"start": "next start",
|
||||
"test": "yarn lint && yarn typecheck && yarn e2e",
|
||||
"typecheck": "tsc --pretty --noEmit"
|
||||
"typecheck": "tsc --pretty --noEmit",
|
||||
"unit": "bun test src"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
@@ -33,9 +34,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "~4.7",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fingerprintjs/fingerprintjs-pro": "^3.5.1",
|
||||
"@material-ui/core": "^4",
|
||||
"@mdx-js/loader": "^2.0.0",
|
||||
"@mui/material": "^5.15.13",
|
||||
"@next/mdx": "^11.0.1",
|
||||
"@noble/ed25519": "1.6.0",
|
||||
"@peculiar/webcrypto": "^1.1.7",
|
||||
@@ -45,9 +49,13 @@
|
||||
"@types/bluebird": "^3.5.33",
|
||||
"@types/lodash-es": "^4.17.3",
|
||||
"@types/react": "18.2.6",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@uiw/codemirror-extensions-zebra-stripes": "^4.21.25",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"bigint-mod-arith": "^3.0.2",
|
||||
"bluebird": "^3.7.2",
|
||||
"codemirror": "^5.62.2",
|
||||
"codemirror": "^6.0.1",
|
||||
"cookies": "^0.8.0",
|
||||
"cypress-mailslurp": "^1.3.0",
|
||||
"email-validator": "^2.0.4",
|
||||
@@ -56,29 +64,30 @@
|
||||
"lodash-es": "^4.17.15",
|
||||
"mailgun-js": "^0.22.0",
|
||||
"moment": "^2.29.1",
|
||||
"next": "11",
|
||||
"next": "12",
|
||||
"next-transpile-modules": "8",
|
||||
"patch-package": "^6.4.7",
|
||||
"pdf-lib": "^1.16.0",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"pusher": "^4.0.2",
|
||||
"pusher-js": "^7.0.2",
|
||||
"qr-code-styling": "^1.6.0-rc.1",
|
||||
"react": "^18.2.0",
|
||||
"react-codemirror2": "scniro/react-codemirror2#0f2bb13",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-flip-move": "^3.0.4",
|
||||
"react-flip-move": "3.0.5",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
"react-scroll": "^1.8.3",
|
||||
"react-signature-pad-wrapper": "^1.3.0",
|
||||
"react-simple-code-editor": "^0.11.0",
|
||||
"react-signature-pad-wrapper": "^3.3.4",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"smoothscroll-polyfill": "^0.4.4",
|
||||
"swr": "^0.4.0",
|
||||
"swr": "2",
|
||||
"throat": "^6.0.1",
|
||||
"timeago-react": "^3.0.4",
|
||||
"timeago.js": "^4.0.2",
|
||||
"ua-parser-js": "^0.7.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.0.8",
|
||||
"@types/cookies": "^0.7.6",
|
||||
"@types/jsonwebtoken": "^8.5.1",
|
||||
"@types/mailgun-js": "^0.22.10",
|
||||
@@ -87,8 +96,8 @@
|
||||
"@types/react-scroll": "^1.8.2",
|
||||
"@types/smoothscroll-polyfill": "^0.3.1",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^5",
|
||||
"@typescript-eslint/parser": "^5",
|
||||
"@typescript-eslint/eslint-plugin": "^6",
|
||||
"@typescript-eslint/parser": "^6",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"cypress": "9.4.1",
|
||||
"cypress-localstorage-commands": "^1.6.1",
|
||||
@@ -97,9 +106,9 @@
|
||||
"eslint-config-prettier": "^6.10.1",
|
||||
"eslint-plugin-cypress": "^2.12.1",
|
||||
"eslint-plugin-react": "^7.20.1",
|
||||
"eslint-plugin-sort-destructure-keys": "^1.3.5",
|
||||
"eslint-plugin-sort-destructure-keys": "1.5.0",
|
||||
"eslint-plugin-sort-keys-fix": "^1.1.1",
|
||||
"eslint-plugin-typescript-sort-keys": "^2",
|
||||
"eslint-plugin-typescript-sort-keys": "3.0",
|
||||
"husky": "^4.2.3",
|
||||
"import-sort-style-module": "^6.0.0",
|
||||
"postcss": "^8.4.23",
|
||||
@@ -107,12 +116,13 @@
|
||||
"prettier-plugin-import-sort": "^0.0.4",
|
||||
"prettier-plugin-packagejson": "^2.2.5",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"typescript": "~4.4"
|
||||
"typescript": "5.4"
|
||||
},
|
||||
"importSort": {
|
||||
".js, .jsx, .ts, .tsx": {
|
||||
"style": "module",
|
||||
"parser": "typescript"
|
||||
}
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72"
|
||||
}
|
||||
|
||||
1
pages/admin/conventions.tsx
Normal file
1
pages/admin/conventions.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ConventionsOverviewPage as default } from 'src/admin/Conventions/ConventionsOverviewPage'
|
||||
1
pages/admin/conventions/[convention_id].tsx
Normal file
1
pages/admin/conventions/[convention_id].tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ManageConventionPage as default } from 'src/admin/Conventions/ManageConventionPage'
|
||||
1
pages/admin/conventions/download.tsx
Normal file
1
pages/admin/conventions/download.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { DownloadQRsPage as default } from 'src/admin/Conventions/DownloadQRsPage'
|
||||
@@ -24,6 +24,10 @@ export const firebase = !Firebase.apps.length
|
||||
})
|
||||
: Firebase.app()
|
||||
|
||||
type SerializedTimestamp = { _seconds: number }
|
||||
/** `new Date()`, which Firebase will automatically serialize into `{ _seconds: number }` */
|
||||
export const newSerializedTimestamp = () => new Date() as unknown as SerializedTimestamp
|
||||
|
||||
/** Init mailgun */
|
||||
export const mailgun = Mailgun({
|
||||
apiKey: MAILGUN_API_KEY as string,
|
||||
|
||||
@@ -5,11 +5,13 @@ import { firebase } from './_services'
|
||||
import { checkJwt } from './validate-admin-jwt'
|
||||
|
||||
export type Election = {
|
||||
ballot_design_finalized?: boolean
|
||||
created_at: { _seconds: number }
|
||||
election_title: string
|
||||
id: string
|
||||
num_voters?: number
|
||||
num_votes?: number
|
||||
threshold_public_key?: string
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
||||
61
pages/api/conventions/[convention_id]/create-qrs.ts
Normal file
61
pages/api/conventions/[convention_id]/create-qrs.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { firebase, newSerializedTimestamp } from 'api/_services'
|
||||
import { checkJwtOwnsConvention } from 'api/validate-admin-jwt'
|
||||
import { firestore } from 'firebase-admin'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { generateAuthToken } from 'src/crypto/generate-auth-tokens'
|
||||
|
||||
import { ensureBallotAuthsForQrs } from './ensure-ballot-auths-for-qrs'
|
||||
|
||||
export type QR_Id = {
|
||||
ballot_auths?: { [ballot_id: string]: string }
|
||||
createdAt: ReturnType<typeof newSerializedTimestamp>
|
||||
index: number
|
||||
qr_id: string
|
||||
setIndex: number
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { convention_id } = req.query
|
||||
if (!convention_id || typeof convention_id !== 'string')
|
||||
return res.status(401).json({ error: `Missing convention_id` })
|
||||
|
||||
const { numQRs } = req.body
|
||||
if (!numQRs || typeof numQRs !== 'number') return res.status(401).json({ error: `Missing numQRs` })
|
||||
|
||||
// Confirm they created this convention
|
||||
const jwt = await checkJwtOwnsConvention(req, res, convention_id)
|
||||
if (!jwt.valid) return
|
||||
|
||||
const doc = firebase.firestore().collection('conventions').doc(convention_id)
|
||||
|
||||
const createdAt = newSerializedTimestamp()
|
||||
|
||||
// Insert new voters in DB
|
||||
const updateConventionDoc = doc.update({
|
||||
num_qrs: firestore.FieldValue.increment(numQRs),
|
||||
qrs: firestore.FieldValue.arrayUnion({ createdAt, number: numQRs }),
|
||||
})
|
||||
|
||||
const { num_qrs: prev_num_qrs = 0 } = jwt
|
||||
const newSetIndex = jwt.qrs?.length || 0
|
||||
|
||||
// Assign unique qr_ids
|
||||
const createNewQrIds = Array.from({ length: numQRs }, () => generateAuthToken()).map((qr_id, i) =>
|
||||
doc
|
||||
.collection('qr_ids')
|
||||
.doc(qr_id)
|
||||
.set({
|
||||
createdAt,
|
||||
index: i + prev_num_qrs + 1,
|
||||
qr_id,
|
||||
setIndex: newSetIndex,
|
||||
} as QR_Id),
|
||||
)
|
||||
|
||||
await Promise.all(createNewQrIds)
|
||||
await updateConventionDoc
|
||||
|
||||
await ensureBallotAuthsForQrs(convention_id)
|
||||
|
||||
return res.status(201).send({ success: true })
|
||||
}
|
||||
35
pages/api/conventions/[convention_id]/download-set.ts
Normal file
35
pages/api/conventions/[convention_id]/download-set.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { firebase } from 'api/_services'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { checkJwtOwnsConvention } from '../../validate-admin-jwt'
|
||||
import { QR_Id } from './create-qrs'
|
||||
|
||||
export type ConventionSet = {
|
||||
convention_title: string
|
||||
qrs: QR_Id[]
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { convention_id, set } = req.query
|
||||
if (!convention_id || typeof convention_id !== 'string')
|
||||
return res.status(401).json({ error: `Missing convention_id` })
|
||||
|
||||
// Confirm they created this convention
|
||||
const jwt = await checkJwtOwnsConvention(req, res, convention_id)
|
||||
if (!jwt.valid) return
|
||||
|
||||
if (!set || typeof set !== 'string') return res.status(401).json({ error: `Missing set` })
|
||||
|
||||
// Get all voters in this set
|
||||
const qrDocs = await firebase
|
||||
.firestore()
|
||||
.collection('conventions')
|
||||
.doc(convention_id)
|
||||
.collection('qr_ids')
|
||||
.where('setIndex', '==', Number(set))
|
||||
.get()
|
||||
|
||||
const qrs = qrDocs.docs.map((d) => d.data())
|
||||
|
||||
res.status(200).send({ convention_title: jwt.convention_title, qrs } as ConventionSet)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { firebase, pushover } from 'api/_services'
|
||||
import { addVotersToElection } from 'api/election/[election_id]/admin/add-voters'
|
||||
|
||||
import { QR_Id } from './create-qrs'
|
||||
|
||||
const qrToEmail = (convention_id: string) => (qr: QR_Id) => `${qr.index}.qr.${qr.qr_id}@convention.${convention_id}`
|
||||
|
||||
/** Make sure all the current QR Codes for a Convention have matching ballot auth tokens for the active redirect
|
||||
* Optionally pass in `election_id` if already known, to skip loading it
|
||||
*/
|
||||
export const ensureBallotAuthsForQrs = async (convention_id: string, known_election_id?: string) => {
|
||||
const conventionDoc = firebase.firestore().collection('conventions').doc(convention_id)
|
||||
|
||||
let ballot_id = known_election_id
|
||||
if (ballot_id === undefined) {
|
||||
// Lookup active redirect
|
||||
const { active_redirect } = (await conventionDoc.get()).data() as { active_redirect?: string }
|
||||
ballot_id = active_redirect
|
||||
}
|
||||
|
||||
// Stop if no active redirect
|
||||
if (!ballot_id || ballot_id === '') return
|
||||
|
||||
// Lookup current QR codes
|
||||
const currentQrs = (await conventionDoc.collection('qr_ids').get()).docs.map((d) => d.data() as QR_Id)
|
||||
|
||||
// Filter out only those that need new ballot auths
|
||||
const needsNewAuth = currentQrs.filter((q) => !(q.ballot_auths || {})[ballot_id])
|
||||
console.log('createBallotAuthsForQrs', { 'New auths needed': needsNewAuth.length, ballot_id, convention_id })
|
||||
|
||||
if (!needsNewAuth.length) return
|
||||
|
||||
// Make sure they're ordered lowest to highest
|
||||
needsNewAuth.sort((a, b) => a.index - b.index)
|
||||
|
||||
// Generate unique emails for each QR
|
||||
const newEmails = needsNewAuth.map(qrToEmail(convention_id))
|
||||
|
||||
// Create and store unique ballot auth tokens for each
|
||||
const { already_added, email_to_auth, unique_new_emails } = await addVotersToElection(newEmails, ballot_id)
|
||||
// Edge case if ballot already saw email, but the ballot_auth wasn't stored on QR_Id doc.
|
||||
if (already_added.length > 0) {
|
||||
console.log('Unexpected error! Already added:', already_added)
|
||||
pushover('Unexpected convention error creating ballot auths', `Already added: ${already_added.join(', ')}`)
|
||||
}
|
||||
|
||||
// To convert unique_new_emails back to QR objects
|
||||
const emailToQR = needsNewAuth.reduce(
|
||||
(memo, q) => ({ ...memo, [qrToEmail(convention_id)(q)]: q }),
|
||||
{} as Record<string, QR_Id>,
|
||||
)
|
||||
|
||||
// Store all the new auth tokens
|
||||
await Promise.all(
|
||||
unique_new_emails.map((email: string) => {
|
||||
const qr = emailToQR[email]
|
||||
const update = { ...qr.ballot_auths, [ballot_id]: email_to_auth[email] }
|
||||
return conventionDoc.collection('qr_ids').doc(qr.qr_id).update({ ballot_auths: update })
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { checkJwtOwnsConvention } from '../../validate-admin-jwt'
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { convention_id } = req.query
|
||||
if (!convention_id || typeof convention_id !== 'string')
|
||||
return res.status(401).json({ error: `Missing convention_id` })
|
||||
|
||||
// Confirm they created this convention
|
||||
const jwt = await checkJwtOwnsConvention(req, res, convention_id)
|
||||
if (!jwt.valid) return
|
||||
|
||||
res.status(200).send({ ...jwt })
|
||||
}
|
||||
44
pages/api/conventions/[convention_id]/load-qr-redirect.ts
Normal file
44
pages/api/conventions/[convention_id]/load-qr-redirect.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { firebase } from 'api/_services'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
type ConventionInfo = {
|
||||
active_redirect?: string
|
||||
convention_title: string
|
||||
}
|
||||
|
||||
export type ConventionRedirectInfo = ConventionInfo & {
|
||||
active_ballot_auth?: string
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { convention_id, qr_id } = req.query
|
||||
if (typeof convention_id !== 'string') return res.status(401).json({ error: `Missing convention_id` })
|
||||
|
||||
if (typeof qr_id !== 'string') return res.status(401).json({ error: `Missing qr_id` })
|
||||
|
||||
// Start loading convention and QR info
|
||||
const conventionDoc = firebase.firestore().collection('conventions').doc(convention_id)
|
||||
const loadConvention = conventionDoc.get()
|
||||
const qrDoc = await conventionDoc.collection('qr_ids').doc(qr_id).get()
|
||||
|
||||
// Do they both exist?
|
||||
if (!(await loadConvention).exists) return res.status(401).json({ error: `Convention not found` })
|
||||
|
||||
const convention = { ...(await loadConvention).data() }
|
||||
|
||||
const conventionInfo = {
|
||||
active_redirect: convention.active_redirect,
|
||||
convention_title: convention.convention_title,
|
||||
} as ConventionInfo
|
||||
|
||||
if (!qrDoc.exists) return res.status(401).json({ error: `QR ID not found`, info: conventionInfo })
|
||||
const qr = { ...qrDoc.data() }
|
||||
const active_ballot_auth = (qr.ballot_auths || {})[convention.active_redirect]
|
||||
|
||||
return res.status(200).send({
|
||||
info: {
|
||||
active_ballot_auth,
|
||||
...conventionInfo,
|
||||
} as ConventionRedirectInfo,
|
||||
})
|
||||
}
|
||||
34
pages/api/conventions/[convention_id]/set-redirect.ts
Normal file
34
pages/api/conventions/[convention_id]/set-redirect.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { firebase } from '../../_services'
|
||||
import { checkJwtOwnsConvention } from '../../validate-admin-jwt'
|
||||
import { ensureBallotAuthsForQrs } from './ensure-ballot-auths-for-qrs'
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { convention_id } = req.query
|
||||
if (typeof convention_id !== 'string') return res.status(401).json({ error: `Missing convention_id` })
|
||||
|
||||
const { election_id } = req.body
|
||||
if (typeof election_id !== 'string') return res.status(401).json({ error: `Missing election_id` })
|
||||
|
||||
// Confirm they created this convention
|
||||
const jwt = await checkJwtOwnsConvention(req, res, convention_id)
|
||||
if (!jwt.valid) return
|
||||
|
||||
const conventionDoc = firebase.firestore().collection('conventions').doc(convention_id)
|
||||
|
||||
// If they're not removing the redirect...
|
||||
if (election_id !== '') {
|
||||
// Confirm they own the election
|
||||
const election = { ...(await firebase.firestore().collection('elections').doc(election_id).get()).data() }
|
||||
if (election.creator !== jwt.email)
|
||||
return res.status(401).send({ error: `This user did not create election ${election_id}` })
|
||||
|
||||
await ensureBallotAuthsForQrs(convention_id, election_id)
|
||||
}
|
||||
|
||||
// Save redirect in DB
|
||||
await conventionDoc.update({ active_redirect: election_id })
|
||||
|
||||
return res.status(201).send({ success: true })
|
||||
}
|
||||
26
pages/api/conventions/create-convention.ts
Normal file
26
pages/api/conventions/create-convention.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { firebase } from '../_services'
|
||||
import { checkJwt } from '../validate-admin-jwt'
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { convention_title } = req.body
|
||||
|
||||
// Confirm they're a valid admin
|
||||
const jwt = checkJwt(req, res)
|
||||
if (!jwt.valid) return
|
||||
|
||||
// Create a new convention
|
||||
const convention_id = Number(new Date()).toString()
|
||||
const convention = firebase.firestore().collection('conventions').doc(convention_id)
|
||||
await convention.set({
|
||||
convention_title,
|
||||
created_at: new Date(),
|
||||
creator: jwt.email,
|
||||
id: convention_id,
|
||||
num_qrs: 0,
|
||||
})
|
||||
|
||||
// Send back our new convention_id
|
||||
return res.status(201).json({ convention_id })
|
||||
}
|
||||
23
pages/api/conventions/list-all-conventions.ts
Normal file
23
pages/api/conventions/list-all-conventions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { firebase } from '../_services'
|
||||
import { checkJwt } from '../validate-admin-jwt'
|
||||
|
||||
export type Convention = {
|
||||
convention_title: string
|
||||
created_at: { _seconds: number }
|
||||
id: string
|
||||
}
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
// Confirm they're a valid admin
|
||||
const jwt = checkJwt(req, res)
|
||||
if (!jwt.valid) return
|
||||
|
||||
// Get all conventions created by this admin
|
||||
const conventions = (
|
||||
await firebase.firestore().collection('conventions').where('creator', '==', jwt.email).get()
|
||||
).docs.reduce((acc: Convention[], doc) => [{ id: doc.id, ...doc.data() } as Convention, ...acc], [])
|
||||
|
||||
res.status(200).send({ conventions })
|
||||
}
|
||||
@@ -6,17 +6,26 @@ import { generateAuthToken } from 'src/crypto/generate-auth-tokens'
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { new_voters } = req.body
|
||||
const { election_id } = req.query as { election_id: string }
|
||||
const { election_id } = req.query
|
||||
|
||||
if (typeof election_id !== 'string') return res.status(401).json({ error: 'Missing election_id' })
|
||||
|
||||
// Confirm they're a valid admin that created this election
|
||||
const jwt = await checkJwtOwnsElection(req, res, election_id)
|
||||
if (!jwt.valid) return
|
||||
|
||||
if (!election_id) return res.status(401).json({ error: 'Missing election_id' })
|
||||
const { already_added, unique_new_emails: unique_new_voters } = await addVotersToElection(new_voters, election_id)
|
||||
|
||||
// Generate auth token for each voter
|
||||
const auth_tokens = new_voters.map(generateAuthToken)
|
||||
return res.status(201).json({ already_added, unique_new_voters })
|
||||
}
|
||||
|
||||
/** IMPORTANT: Assumes you already checked user owns election
|
||||
*
|
||||
* 1. Adds non-duplicate email addresses to ballot voter list
|
||||
* 2. Creates and stores unique auth tokens for each
|
||||
* 3. Updates election's num_voters tally
|
||||
*/
|
||||
export async function addVotersToElection(new_voters: string[], election_id: string) {
|
||||
// Get existing voter from DB
|
||||
const electionDoc = firebase.firestore().collection('elections').doc(election_id)
|
||||
const loadVoters = electionDoc.collection('voters').get()
|
||||
@@ -27,33 +36,36 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const deduped_new_voters = Array.from(new Set(new_voters)) as string[]
|
||||
|
||||
// Separate uniques from already_added
|
||||
const unique_new_voters: string[] = []
|
||||
const unique_new_emails: string[] = []
|
||||
const already_added: string[] = []
|
||||
deduped_new_voters.forEach((v: string) => {
|
||||
if (v) {
|
||||
existing_voters.has(v) ? already_added.push(v) : unique_new_voters.push(v)
|
||||
existing_voters.has(v) ? already_added.push(v) : unique_new_emails.push(v)
|
||||
}
|
||||
})
|
||||
|
||||
console.log({ already_added, unique_new_voters })
|
||||
console.log('Add-voters:', { already_added, election_id, unique_new_emails })
|
||||
const email_to_auth: Record<string, string> = {}
|
||||
|
||||
// Add uniques to DB
|
||||
// Generate and store auths for uniques
|
||||
await Promise.all(
|
||||
unique_new_voters
|
||||
.map((voter: string, index: number) =>
|
||||
electionDoc
|
||||
unique_new_emails
|
||||
.map((email: string, index: number) => {
|
||||
const auth_token = generateAuthToken()
|
||||
email_to_auth[email] = auth_token
|
||||
return electionDoc
|
||||
.collection('voters')
|
||||
.doc(voter)
|
||||
.doc(email)
|
||||
.set({
|
||||
added_at: new Date(),
|
||||
auth_token: auth_tokens[index],
|
||||
email: voter,
|
||||
auth_token,
|
||||
email,
|
||||
index: index + existing_voters.size,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
// Increment electionDoc's num_voters cached tally
|
||||
.concat(electionDoc.update({ num_voters: firestore.FieldValue.increment(unique_new_voters.length) })),
|
||||
.concat(electionDoc.update({ num_voters: firestore.FieldValue.increment(unique_new_emails.length) })),
|
||||
)
|
||||
|
||||
return res.status(201).json({ already_added, unique_new_voters })
|
||||
return { already_added, email_to_auth, unique_new_emails }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Item } from 'src/vote/storeElectionInfo'
|
||||
|
||||
export type ElectionInfo = {
|
||||
ballot_design?: Item[]
|
||||
ballot_design_finalized?: boolean
|
||||
election_title?: string
|
||||
esignature_requested?: boolean
|
||||
g?: string
|
||||
@@ -41,6 +42,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
||||
const {
|
||||
ballot_design,
|
||||
ballot_design_finalized,
|
||||
election_title,
|
||||
esignature_requested,
|
||||
g,
|
||||
@@ -52,6 +54,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
|
||||
const info: ElectionInfo = {
|
||||
ballot_design: ballot_design ? JSON.parse(ballot_design) : undefined,
|
||||
ballot_design_finalized,
|
||||
election_title,
|
||||
esignature_requested,
|
||||
g,
|
||||
|
||||
@@ -34,16 +34,14 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (!ADMIN_EMAIL) return res.status(501).send('Missing process.env.ADMIN_EMAIL')
|
||||
|
||||
const { election_id } = req.query
|
||||
if (typeof election_id !== 'string') return res.status(400).send('Malformed election_id')
|
||||
const { body } = req
|
||||
const { auth, email } = body
|
||||
console.log('/api/update received:', body)
|
||||
|
||||
if (!email) return res.status(404).end()
|
||||
|
||||
const electionDoc = firebase
|
||||
.firestore()
|
||||
.collection('elections')
|
||||
.doc(election_id as string)
|
||||
const electionDoc = firebase.firestore().collection('elections').doc(election_id)
|
||||
|
||||
// Begin preloading these docs
|
||||
const trusteeDoc = electionDoc.collection('trustees').doc(email)
|
||||
|
||||
30
pages/api/ukraine-updates-subscribe.ts
Normal file
30
pages/api/ukraine-updates-subscribe.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { validate as validateEmail } from 'email-validator'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { allowCors } from './_cors'
|
||||
import { firebase, pushover } from './_services'
|
||||
|
||||
export default allowCors(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { email: untrimmed } = req.body
|
||||
const email = untrimmed.trim()
|
||||
|
||||
// Validate email
|
||||
if (!email) return res.status(400).json({ error: 'Email is required' })
|
||||
if (!validateEmail(email)) return res.status(400).json({ error: 'Invalid email' })
|
||||
|
||||
// Store submission in Firestore
|
||||
await firebase
|
||||
.firestore()
|
||||
.collection('ukraine-updates')
|
||||
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
|
||||
.set({
|
||||
created_at: new Date().toString(),
|
||||
email,
|
||||
})
|
||||
|
||||
// Notify admin via Pushover
|
||||
pushover(`SIV ukraine signup`, email)
|
||||
|
||||
// Send back success
|
||||
return res.status(201).json({ success: true })
|
||||
})
|
||||
@@ -60,7 +60,7 @@ export async function checkJwtOwnsElection(
|
||||
return { res: res.status(401).send({ error: `This user did not create election ${election_id}` }), valid: false }
|
||||
}
|
||||
|
||||
// Other it passes
|
||||
// Otherwise it passes
|
||||
return {
|
||||
ballot_design_finalized: election.ballot_design_finalized,
|
||||
election_manager: election.election_manager,
|
||||
@@ -68,3 +68,43 @@ export async function checkJwtOwnsElection(
|
||||
...jwt_status,
|
||||
}
|
||||
}
|
||||
|
||||
export type Convention = {
|
||||
active_redirect?: string
|
||||
convention_title: string
|
||||
created_at: { _seconds: number }
|
||||
creator: string
|
||||
id: string
|
||||
num_qrs: number
|
||||
qrs: { createdAt: { _seconds: number }; number: number }[]
|
||||
}
|
||||
|
||||
export async function checkJwtOwnsConvention(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
convention_id: string,
|
||||
): Promise<{ res: void; valid: false } | ({ valid: true } & Convention & JWT_Payload)> {
|
||||
const jwt_status = checkJwt(req, res)
|
||||
|
||||
// Fail immediately if checkJwt failed
|
||||
if (!jwt_status.valid) return jwt_status
|
||||
|
||||
// Grab this convention info
|
||||
const convention = {
|
||||
...(await firebase.firestore().collection('conventions').doc(convention_id).get()).data(),
|
||||
} as Convention
|
||||
|
||||
// Check if this this admin is the creator of the given convention
|
||||
if (jwt_status.email !== convention.creator) {
|
||||
return {
|
||||
res: res.status(401).send({ error: `This user did not create convention ${convention_id}` }),
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise it passes
|
||||
return {
|
||||
...convention,
|
||||
...jwt_status,
|
||||
}
|
||||
}
|
||||
|
||||
1
pages/c/[convention_id]/[qr_id].tsx
Normal file
1
pages/c/[convention_id]/[qr_id].tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ConventionQRPage as default } from 'src/convention/ConventionQRPage'
|
||||
1
pages/intro.tsx
Normal file
1
pages/intro.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { IntroPage as default } from '../src/intro/IntroPage'
|
||||
@@ -1,14 +0,0 @@
|
||||
diff --git a/node_modules/next/dist/lib/typescript/missingDependencyError.js b/node_modules/next/dist/lib/typescript/missingDependencyError.js
|
||||
index 6c0cf4f..41e37c7 100644
|
||||
--- a/node_modules/next/dist/lib/typescript/missingDependencyError.js
|
||||
+++ b/node_modules/next/dist/lib/typescript/missingDependencyError.js
|
||||
@@ -18,7 +18,7 @@ function missingDepsError(dir, missingPackages) {
|
||||
const packagesCli = missingPackages.map((p)=>p.pkg
|
||||
).join(' ');
|
||||
const removalMsg = '\n\n' + _chalk.default.bold('If you are not trying to use TypeScript, please remove the ' + _chalk.default.cyan('tsconfig.json') + ' file from your package root (and any TypeScript files in your pages directory).');
|
||||
- throw new _fatalError.FatalError(_chalk.default.bold.red(`It looks like you're trying to use TypeScript but do not have the required package(s) installed.`) + '\n\n' + _chalk.default.bold(`Please install ${_chalk.default.bold(packagesHuman)} by running:`) + '\n\n' + `\t${_chalk.default.bold.cyan(((0, _isYarn).isYarn(dir) ? 'yarn add --dev' : 'npm install --save-dev') + ' ' + packagesCli)}` + removalMsg + '\n');
|
||||
+ // throw new _fatalError.FatalError(_chalk.default.bold.red(`It looks like you're trying to use TypeScript but do not have the required package(s) installed.`) + '\n\n' + _chalk.default.bold(`Please install ${_chalk.default.bold(packagesHuman)} by running:`) + '\n\n' + `\t${_chalk.default.bold.cyan(((0, _isYarn).isYarn(dir) ? 'yarn add --dev' : 'npm install --save-dev') + ' ' + packagesCli)}` + removalMsg + '\n');
|
||||
}
|
||||
|
||||
//# sourceMappingURL=missingDependencyError.js.map
|
||||
\ No newline at end of file
|
||||
BIN
public/VOTE.png
Normal file
BIN
public/VOTE.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
350
src/TailwindPreflight.tsx
Normal file
350
src/TailwindPreflight.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
// Lightly adapted from node_modules/tailwindcss/lib/css/preflight.css
|
||||
// for importing into pages one-by-one
|
||||
//
|
||||
// Key change:
|
||||
// 6 instances: theme($1) -> $1
|
||||
// Resolving variable gray-400 to its hex
|
||||
|
||||
export const TailwindPreflight = () => {
|
||||
return (
|
||||
<style global jsx>{`
|
||||
/*
|
||||
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||
*/
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box; /* 1 */
|
||||
border-width: 0; /* 2 */
|
||||
border-style: solid; /* 2 */
|
||||
border-color: #dae4e9; /* 2 */
|
||||
}
|
||||
|
||||
::before,
|
||||
::after {
|
||||
--tw-content: '';
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use a consistent sensible line-height in all browsers.
|
||||
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
3. Use a more readable tab size.
|
||||
4. Use the 'sans' font-family by default.
|
||||
*/
|
||||
html {
|
||||
line-height: 1.5; /* 1 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
-moz-tab-size: 4; /* 3 */
|
||||
tab-size: 4; /* 3 */
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; /* 4 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove the margin in all browsers.
|
||||
2. Inherit line-height from 'html' so users can set them as a class directly on the 'html' element.
|
||||
*/
|
||||
body {
|
||||
margin: 0; /* 1 */
|
||||
line-height: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Add the correct height in Firefox.
|
||||
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||
3. Ensure horizontal rules are visible by default.
|
||||
*/
|
||||
hr {
|
||||
height: 0; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
border-top-width: 1px; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||
*/
|
||||
abbr:where([title]) {
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the default font size and weight for headings.
|
||||
*/
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Reset links to optimize for opt-in styling instead of opt-out.
|
||||
*/
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font weight in Edge and Safari.
|
||||
*/
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Use the 'mono' font family by default.
|
||||
2. Correct the odd 'em' font sizing in all browsers.
|
||||
*/
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
|
||||
*/
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
3. Remove gaps between table borders by default.
|
||||
*/
|
||||
table {
|
||||
text-indent: 0; /* 1 */
|
||||
border-color: inherit; /* 2 */
|
||||
border-collapse: collapse; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
1. Change the font styles in all browsers.
|
||||
2. Remove the margin in Firefox and Safari.
|
||||
3. Remove default padding in all browsers.
|
||||
*/
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
font-weight: inherit; /* 1 */
|
||||
line-height: inherit; /* 1 */
|
||||
color: inherit; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
padding: 0; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inheritance of text transform in Edge and Firefox.
|
||||
*/
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Remove default button styles.
|
||||
*/
|
||||
button,
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'] {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
background-color: transparent; /* 2 */
|
||||
background-image: none; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Use the modern Firefox focus style for all focusable elements.
|
||||
*/
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the additional ':invalid' styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
|
||||
*/
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/*
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the odd appearance in Chrome and Safari.
|
||||
2. Correct the outline style in Safari.
|
||||
*/
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Change font properties to 'inherit' in Safari.
|
||||
*/
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/*
|
||||
Removes the default spacing and border for appropriate elements.
|
||||
*/
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr,
|
||||
figure,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
menu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Prevent resizing textareas horizontally by default.
|
||||
*/
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
|
||||
2. Set the default placeholder color to gray-400.
|
||||
*/
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 1; /* 1 */
|
||||
color: #9ca3af; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Set the default cursor for buttons.
|
||||
*/
|
||||
button,
|
||||
[role='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
Make sure disabled buttons don't get the pointer cursor.
|
||||
*/
|
||||
:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/*
|
||||
1. Make replaced elements 'display: block' by default. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
2. Add 'vertical-align: middle' to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
|
||||
This can trigger a poorly considered lint error in some tools but is included by design.
|
||||
*/
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block; /* 1 */
|
||||
vertical-align: middle; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||
*/
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Make elements with the HTML hidden attribute stay hidden by default */
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
)
|
||||
}
|
||||
@@ -54,14 +54,14 @@ type OnClickProps = ButtonProps & {
|
||||
export const OnClickButton = forwardRef<HTMLAnchorElement, OnClickProps>(
|
||||
(
|
||||
{
|
||||
background,
|
||||
children,
|
||||
disabled,
|
||||
disabledExplanation,
|
||||
onClick,
|
||||
invertColor,
|
||||
id,
|
||||
background,
|
||||
invertColor,
|
||||
noBorder,
|
||||
onClick,
|
||||
style = {},
|
||||
}: OnClickProps,
|
||||
ref,
|
||||
|
||||
10
src/_shared/Forms/Row.tsx
Normal file
10
src/_shared/Forms/Row.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export const Row = (props: { children?: React.ReactNode; className?: string; style?: React.CSSProperties }) => (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
display: 'flex',
|
||||
margin: '1.5rem 0',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
25
src/_shared/NoSsr.tsx
Normal file
25
src/_shared/NoSsr.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface NoSSRProps {
|
||||
/** The content to render on client. */
|
||||
children?: React.JSX.Element | (React.JSX.Element | false)[]
|
||||
/** Optional content to show before the component renders on client. This renders during server-side rendering (SSR). */
|
||||
onSSR?: React.FC
|
||||
}
|
||||
|
||||
const EmptySpan = () => <span />
|
||||
|
||||
export const NoSsr = (props: NoSSRProps) => {
|
||||
const { children = <EmptySpan />, onSSR = EmptySpan } = props
|
||||
|
||||
const [isMounted, setIsMounted] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
return () => {
|
||||
setIsMounted(false)
|
||||
}
|
||||
})
|
||||
|
||||
return isMounted ? <>{children}</> : onSSR({})
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import React, { Fragment } from 'react'
|
||||
import { darkBlue } from 'src/homepage/colors'
|
||||
|
||||
import { research } from './research'
|
||||
import { Team } from './Team'
|
||||
|
||||
// import { Team } from './Team'
|
||||
|
||||
export const Content = () => (
|
||||
<main>
|
||||
@@ -42,7 +43,7 @@ export const Content = () => (
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<Team />
|
||||
{/* <Team /> */}
|
||||
|
||||
<style jsx>{`
|
||||
main {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Head } from '../Head'
|
||||
import { AllYourElections } from './AllYourElections'
|
||||
import { useLoginRequired, useUser } from './auth'
|
||||
import { BallotDesign } from './BallotDesign/BallotDesign'
|
||||
import { YourConventionsLink } from './Conventions/YourConventionsLink'
|
||||
import { CreateNewElection } from './CreateNewElection'
|
||||
import { HeaderBar } from './HeaderBar'
|
||||
import { MarkedBallots } from './MarkedBallots/MarkedBallots'
|
||||
@@ -20,53 +21,35 @@ export const AdminPage = (): JSX.Element => {
|
||||
|
||||
useLoginRequired(loggedOut)
|
||||
usePusher(election_id as string | undefined)
|
||||
if (loading || loggedOut) return <p className="p-4 text-[21px]">Loading...</p>
|
||||
|
||||
if (loading || loggedOut) return <p style={{ fontSize: 21, padding: '1rem' }}>Loading...</p>
|
||||
return (
|
||||
<>
|
||||
<Head title="Create new election" />
|
||||
|
||||
<HeaderBar />
|
||||
<main>
|
||||
<main className="flex w-full">
|
||||
<Sidebar />
|
||||
<div id="main-content">
|
||||
<div
|
||||
className="w-full p-4 overflow-auto absolute top-[66px] right-0 bottom-0 sm:left-[215px] sm:px-8 sm:w-auto"
|
||||
id="main-content"
|
||||
>
|
||||
<MobileMenu />
|
||||
{!election_id && <AllYourElections />}
|
||||
{!election_id && <CreateNewElection />}
|
||||
|
||||
{!election_id && (
|
||||
<>
|
||||
<AllYourElections />
|
||||
<CreateNewElection />
|
||||
<YourConventionsLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === 'observers' && <Observers />}
|
||||
{section === 'ballot-design' && <BallotDesign />}
|
||||
{section === 'voters' && <AddVoters />}
|
||||
{section === 'marked-ballots' && <MarkedBallots />}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style jsx>{`
|
||||
main {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#main-content {
|
||||
padding: 1rem 2rem;
|
||||
overflow: auto;
|
||||
position: absolute;
|
||||
left: 215px;
|
||||
top: 66px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* When sidebar disappears */
|
||||
@media (max-width: 640px) {
|
||||
#main-content {
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<GlobalCSS />
|
||||
<style global jsx>{`
|
||||
body {
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import { Election } from 'api/admin-all-elections'
|
||||
import Link from 'next/link'
|
||||
import { useReducer } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import TimeAgo from 'timeago-react'
|
||||
|
||||
import { Election } from '../../pages/api/admin-all-elections'
|
||||
export const fetcher = (url: string) =>
|
||||
fetch(url).then(async (resp) => {
|
||||
if (!resp.ok) throw await resp.json()
|
||||
return await resp.json()
|
||||
})
|
||||
export const useAllYourElections = () => useSWR('/api/admin-all-elections', fetcher)
|
||||
|
||||
export const AllYourElections = () => {
|
||||
const [show, toggle] = useReducer((state) => !state, false)
|
||||
|
||||
const { data } = useSWR('/api/admin-all-elections', (url: string) =>
|
||||
fetch(url).then(async (r) => {
|
||||
if (!r.ok) throw await r.json()
|
||||
return await r.json()
|
||||
}),
|
||||
)
|
||||
const { data } = useAllYourElections()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>
|
||||
Your Elections: <i>{data?.elections?.length}</i>{' '}
|
||||
Your Ballots: <span className="font-normal">{data?.elections?.length}</span>{' '}
|
||||
{!!data?.elections?.length && (
|
||||
<span>
|
||||
<a onClick={toggle}>[ {show ? '- Hide' : '+ Show'} ]</a>
|
||||
</span>
|
||||
<a className="text-[14px] font-normal ml-1 cursor-pointer" onClick={toggle}>
|
||||
[ {show ? '- Hide' : '+ Show'} ]
|
||||
</a>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{show && (
|
||||
<ul>
|
||||
{data?.elections?.map(({ created_at, election_title, id }: Election) => (
|
||||
@@ -39,23 +41,6 @@ export const AllYourElections = () => {
|
||||
<br />
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
h2 i {
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
h2 span {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
h2 span:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NoSsr } from '@material-ui/core'
|
||||
import router from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
|
||||
import { api } from '../../api-helper'
|
||||
import { SaveButton } from '../SaveButton'
|
||||
@@ -39,9 +39,22 @@ export const BallotDesign = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="hidden sm:block">Ballot Design</h2>
|
||||
<h2 className="hidden sm:block">
|
||||
Ballot Design
|
||||
{/* Preview button */}
|
||||
<a
|
||||
className="inline-block text-[12px] font-semibold !no-underline hover:bg-gray-100 ml-3 px-3 py-1 border border-blue-900 border-solid rounded-lg relative bottom-0.5 text-blue-900"
|
||||
href={`/election/${election_id}/vote?auth=preview`}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
🔍 Preview
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<AutoSaver {...{ design }} />
|
||||
<Errors {...{ error }} />
|
||||
|
||||
<ModeControls {...{ selected, setSelected }} />
|
||||
<div className="mode-container">
|
||||
{selected !== 1 && <Wizard {...{ design, setDesign }} />}
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import 'codemirror/lib/codemirror.css'
|
||||
import 'codemirror/theme/idea.css'
|
||||
import 'codemirror/mode/javascript/javascript'
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { tags as t } from '@lezer/highlight'
|
||||
import { githubLightInit } from '@uiw/codemirror-theme-github'
|
||||
import CodeMirror, { ReactCodeMirrorProps } from '@uiw/react-codemirror'
|
||||
|
||||
import { Controlled as CodeMirror } from 'react-codemirror2'
|
||||
const CodeMirrorComponent = (props: ReactCodeMirrorProps) => (
|
||||
<>
|
||||
<CodeMirror
|
||||
{...props}
|
||||
basicSetup={{ foldGutter: false }}
|
||||
extensions={[json()]}
|
||||
theme={githubLightInit({
|
||||
settings: { foreground: 'hsl(0, 0%, 60%)', gutterBackground: '#f8f8f8', gutterForeground: '#999' },
|
||||
styles: [
|
||||
{ color: 'rgb(23, 26, 79)', tag: t.propertyName },
|
||||
{ color: 'green', tag: t.string },
|
||||
{ color: 'rgb(116, 69, 0)', fontWeight: 'bold', tag: t.bool },
|
||||
],
|
||||
})}
|
||||
/>
|
||||
<style global jsx>{`
|
||||
.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
|
||||
export default CodeMirror
|
||||
export default CodeMirrorComponent
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import Switch, { SwitchClassKey, SwitchProps } from '@material-ui/core/Switch'
|
||||
|
||||
const height = 22
|
||||
|
||||
interface Styles extends Partial<Record<SwitchClassKey, string>> {
|
||||
focusVisible?: string
|
||||
}
|
||||
|
||||
export const IOSSwitch = withStyles((theme: Theme) => ({
|
||||
checked: {},
|
||||
focusVisible: {},
|
||||
root: {
|
||||
height,
|
||||
margin: 5,
|
||||
padding: 0,
|
||||
width: height * 2 - 6,
|
||||
},
|
||||
switchBase: {
|
||||
'&$checked': {
|
||||
'& + $track': {
|
||||
backgroundColor: '#009319',
|
||||
border: 'none',
|
||||
opacity: 1,
|
||||
},
|
||||
color: theme.palette.common.white,
|
||||
transform: 'translateX(16px)',
|
||||
},
|
||||
'&$focusVisible $thumb': {
|
||||
border: '6px solid #fff',
|
||||
color: '#009319',
|
||||
},
|
||||
padding: 1,
|
||||
},
|
||||
thumb: {
|
||||
height: height - 2,
|
||||
width: height - 2,
|
||||
},
|
||||
track: {
|
||||
backgroundColor: theme.palette.grey[50],
|
||||
border: `1px solid ${theme.palette.grey[400]}`,
|
||||
borderRadius: 26 / 2,
|
||||
opacity: 1,
|
||||
transition: theme.transitions.create(['background-color', 'border']),
|
||||
},
|
||||
}))(({ classes, ...props }: { classes: Styles } & SwitchProps) => {
|
||||
return (
|
||||
<Switch
|
||||
disableRipple
|
||||
classes={{
|
||||
checked: classes.checked,
|
||||
root: classes.root,
|
||||
switchBase: classes.switchBase,
|
||||
thumb: classes.thumb,
|
||||
track: classes.track,
|
||||
}}
|
||||
focusVisibleClassName={classes.focusVisible}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
17
src/admin/BallotDesign/Switch.tsx
Normal file
17
src/admin/BallotDesign/Switch.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export const Switch = ({ checked, label, onClick }: { checked: boolean; label: string; onClick: () => void }) => {
|
||||
return (
|
||||
<span {...{ onClick }}>
|
||||
<input
|
||||
readOnly
|
||||
className="relative top-2.5 border-solid border border-neutral-400/60 h-[22px] w-[38px] appearance-none rounded-full bg-neutral-100 after:absolute after:z-[2] after:h-[18px] after:w-[18px] after:rounded-full after:shadow-[0_1px_2px_-1px_rgba(0,0,0,0.6)] after:bg-neutral-50 after:ml-px after:mt-px after:transition-[transform_0.2s] after:content-[''] transition-[background-color_0.2s] checked:bg-[#009319] checked:after:ml-[17px] focus:outline-none focus:ring-0 cursor-pointer"
|
||||
id={`switch-${label}`}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
{...{ checked }}
|
||||
/>
|
||||
<label className="pl-[0.15rem] relative bottom-0.5 cursor-pointer" htmlFor={`switch-${label}`} {...{ onClick }}>
|
||||
{label}
|
||||
</label>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,16 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const CodeMirror = dynamic(import('./CodeMirror'), { ssr: false })
|
||||
const CodeMirror = dynamic(() => import('./CodeMirror'), { ssr: false })
|
||||
|
||||
export const TextDesigner = ({ design, setDesign }: { design: string; setDesign: (s: string) => void }) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="relative flex-1">
|
||||
<CodeMirror
|
||||
options={{
|
||||
lineNumbers: true,
|
||||
mode: 'application/json',
|
||||
theme: 'idea',
|
||||
}}
|
||||
height="auto"
|
||||
style={{ border: '1px solid #ccc', borderRadius: 3, borderTopRightRadius: 0 }}
|
||||
value={design}
|
||||
onBeforeChange={(editor, data, value) => {
|
||||
setDesign(value)
|
||||
}}
|
||||
onChange={(value) => setDesign(value)}
|
||||
/>
|
||||
|
||||
<style jsx>{`
|
||||
div {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
`}</style>
|
||||
<style global jsx>{`
|
||||
.react-codemirror2 {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border-top-right-radius: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.CodeMirror {
|
||||
height: unset;
|
||||
color: hsl(0, 0%, 58%) !important;
|
||||
}
|
||||
span.cm-property {
|
||||
color: rgb(23, 26, 79) !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import { Tooltip } from 'src/admin/Voters/Tooltip'
|
||||
import { Item } from 'src/vote/storeElectionInfo'
|
||||
|
||||
import { check_for_urgent_ballot_errors } from './check_for_ballot_errors'
|
||||
import { IOSSwitch } from './IOSSwitch'
|
||||
import { Switch } from './Switch'
|
||||
|
||||
export const default_multiple_votes_allowed = 3
|
||||
export const default_min_score = -5
|
||||
export const default_max_score = 5
|
||||
|
||||
export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: string) => void }) => {
|
||||
const [json, setJson] = useState<Item[]>()
|
||||
const saveDesign = (json: Item[]) => setDesign(JSON.stringify(json, undefined, 2))
|
||||
|
||||
const errors = check_for_urgent_ballot_errors(design)
|
||||
|
||||
@@ -22,175 +27,217 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
|
||||
errors ? 'bg-gray-100 opacity-30 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{json?.map(({ id, multiple_votes_allowed, options, title, write_in_allowed }, questionIndex) => (
|
||||
// Each question
|
||||
<div className="p-2.5 bg-white mt-4 first:mt-0" key={questionIndex}>
|
||||
{/* Question ID Label */}
|
||||
<label className="block mt-3.5 text-[10px] italic">
|
||||
Question ID{' '}
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={
|
||||
<span style={{ fontSize: 14 }}>
|
||||
This unique short ID is used as the column header for the table of submitted votes.
|
||||
{json?.map(
|
||||
({ id, max_score, min_score, multiple_votes_allowed, options, title, write_in_allowed }, questionIndex) => (
|
||||
// Each question
|
||||
<div className="p-2.5 bg-white mt-4 first:mt-0" key={questionIndex}>
|
||||
{/* Question ID Label */}
|
||||
<label className="block mt-3.5 text-[10px] italic">
|
||||
Question ID{' '}
|
||||
<Tooltip
|
||||
placement="top"
|
||||
tooltip={
|
||||
<span style={{ fontSize: 14 }}>
|
||||
This unique short ID is used as the column header for the table of submitted votes.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="relative top-0 text-sm text-indigo-500 left-1">
|
||||
<QuestionCircleOutlined />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="relative top-0 text-sm text-indigo-500 left-1">
|
||||
<QuestionCircleOutlined />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</label>
|
||||
{/* Question ID Input */}
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
className="p-1 text-sm"
|
||||
value={id}
|
||||
onChange={({ target }) => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].id = target.value
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete Question btn */}
|
||||
<Tooltip placement="top" title="Delete Question">
|
||||
<a
|
||||
className="relative ml-1 text-xl text-center text-gray-700 rounded-full cursor-pointer w-7 bottom-[30px] hover:bg-gray-500 hover:text-white"
|
||||
onClick={() => {
|
||||
const new_json = [...json]
|
||||
new_json.splice(questionIndex, 1)
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
}}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Type selector */}
|
||||
<div className="mt-4">
|
||||
<label className="text-[10px] italic">Voting Type:</label>
|
||||
|
||||
{/* Type dropdown */}
|
||||
<div className="relative">
|
||||
<span className="absolute z-20 scale-75 right-3 top-2 opacity-60">▼</span>
|
||||
<select
|
||||
className="appearance-none border border-solid border-gray-200 text-[13px] rounded focus:ring-blue-500 focus:border-blue-500 w-full p-2.5 shadow-sm relative"
|
||||
value={json[questionIndex].type}
|
||||
onChange={({ target }) => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].type = target.value as string
|
||||
if (
|
||||
new_json[questionIndex].type == 'multiple-votes-allowed' &&
|
||||
!new_json[questionIndex].multiple_votes_allowed
|
||||
) {
|
||||
new_json[questionIndex].multiple_votes_allowed = 3
|
||||
}
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
}}
|
||||
>
|
||||
<option value="choose-only-one">Choose Only One — Plurality (FPTP)</option>
|
||||
<option value="ranked-choice-irv">Ranked Choice — Instant Runoff (IRV)</option>
|
||||
<option value="approval">Approval — Vote for All That You Approve Of</option>
|
||||
<option value="multiple-votes-allowed">Multiple Votes Allowed — Choose Up to X</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{json[questionIndex].type == 'multiple-votes-allowed' && (
|
||||
<div className="mt-1">
|
||||
<label className="text-[10px] italic">Max Selections Allowed?</label>
|
||||
</Tooltip>
|
||||
</label>
|
||||
{/* Question ID Input */}
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
className="p-1 ml-1 text-sm"
|
||||
value={multiple_votes_allowed}
|
||||
className="p-1 text-sm"
|
||||
value={id}
|
||||
onChange={({ target }) => {
|
||||
const update = +target.value
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].multiple_votes_allowed = update
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
new_json[questionIndex].id = target.value
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Title Label */}
|
||||
<label className="block mt-4 text-[10px] italic">Question Title:</label>
|
||||
{/* Question Title Input */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
className="flex-1 p-1 text-sm"
|
||||
value={title}
|
||||
onChange={({ target }) => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].title = target.value
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<ul>
|
||||
{options?.map(({ name }, optionIndex) => (
|
||||
<li key={optionIndex}>
|
||||
{/* Option input */}
|
||||
<input
|
||||
className="p-[5px] mb-1.5 text-[13px]"
|
||||
value={name}
|
||||
onChange={({ target }) => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].options[optionIndex].name = target.value
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
}}
|
||||
/>
|
||||
{/* Delete Option btn */}
|
||||
{/* Delete Question btn */}
|
||||
<Tooltip placement="top" tooltip="Delete Question">
|
||||
<a
|
||||
className="inline-block pl-px w-5 h-5 ml-[5px] leading-4 text-center text-gray-600 border border-gray-300 border-solid rounded-full cursor-pointer hover:no-underline hover:bg-gray-500 hover:border-transparent hover:text-white"
|
||||
className="relative ml-1 text-xl text-center text-gray-700 rounded-full cursor-pointer w-7 bottom-[30px] hover:bg-gray-500 hover:text-white"
|
||||
onClick={() => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].options.splice(optionIndex, 1)
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
new_json.splice(questionIndex, 1)
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
<DeleteOutlined />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{/* Add another option btn */}
|
||||
<a
|
||||
className="block pl-2 mt-1 mb-[14px] text-[13px] italic cursor-pointer"
|
||||
onClick={() => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].options.push({ name: '' })
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
}}
|
||||
>
|
||||
+ Add another option
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Write-in Allowed toggle */}
|
||||
<li className={`${write_in_allowed ? '' : 'list-none'}`}>
|
||||
<span
|
||||
className={`inline-block w-32 pl-2 text-[13px] italic text-gray-800 ${
|
||||
!write_in_allowed ? 'opacity-60' : ''
|
||||
}`}
|
||||
>{`Write-in ${write_in_allowed ? 'Allowed' : 'Disabled'}`}</span>
|
||||
<IOSSwitch
|
||||
checked={write_in_allowed}
|
||||
onChange={() => {
|
||||
{/* Type selector */}
|
||||
<div className="mt-4">
|
||||
<label className="text-[10px] italic">Voting Type:</label>
|
||||
|
||||
{/* Type dropdown */}
|
||||
<div className="relative">
|
||||
<span className="absolute z-20 scale-75 right-3 top-2 opacity-60">▼</span>
|
||||
<select
|
||||
className="appearance-none border border-solid border-gray-200 text-[13px] rounded focus:ring-blue-500 focus:border-blue-500 w-full p-2.5 shadow-sm relative"
|
||||
value={json[questionIndex].type}
|
||||
onChange={({ target }) => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].type = target.value as string
|
||||
if (
|
||||
new_json[questionIndex].type == 'multiple-votes-allowed' &&
|
||||
!new_json[questionIndex].multiple_votes_allowed
|
||||
) {
|
||||
new_json[questionIndex].multiple_votes_allowed = default_multiple_votes_allowed
|
||||
}
|
||||
if (
|
||||
(new_json[questionIndex].type == 'score' && !new_json[questionIndex].min_score) ||
|
||||
!new_json[questionIndex].max_score
|
||||
) {
|
||||
new_json[questionIndex].min_score = default_min_score
|
||||
new_json[questionIndex].max_score = default_max_score
|
||||
}
|
||||
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
>
|
||||
<option value="choose-only-one">Choose Only One — Plurality (FPTP)</option>
|
||||
<option value="ranked-choice-irv">Ranked Choice — Instant Runoff (IRV)</option>
|
||||
<option value="approval">Approval — Vote for All That You Approve Of</option>
|
||||
<option value="multiple-votes-allowed">Multiple Votes Allowed — Choose Up to X</option>
|
||||
<option value="score">Score — Scale from Low to High</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{json[questionIndex].type == 'multiple-votes-allowed' && (
|
||||
<div className="mt-1">
|
||||
<label className="text-[10px] italic">Max Selections Allowed?</label>
|
||||
<input
|
||||
className="p-1 ml-1 text-sm"
|
||||
value={multiple_votes_allowed}
|
||||
onChange={({ target }) => {
|
||||
const update = +target.value
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].multiple_votes_allowed = update
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{json[questionIndex].type == 'score' && (
|
||||
<div className="mt-1">
|
||||
<label className="text-[10px] italic">Min score?</label>
|
||||
<input
|
||||
className="p-1 m-1 text-sm w-14"
|
||||
type="number"
|
||||
value={min_score}
|
||||
onChange={({ target }) => {
|
||||
const update = +target.value
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].min_score = update
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
/>
|
||||
|
||||
<label className="ml-5 text-[10px] italic">Max score?</label>
|
||||
<input
|
||||
className="p-1 ml-1 text-sm w-14"
|
||||
type="number"
|
||||
value={max_score}
|
||||
onChange={({ target }) => {
|
||||
const update = +target.value
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].max_score = update
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question Title Label */}
|
||||
<label className="block mt-4 text-[10px] italic">Question Title:</label>
|
||||
{/* Question Title Input */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
className="flex-1 p-1 text-sm"
|
||||
value={title}
|
||||
onChange={({ target }) => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].write_in_allowed = !write_in_allowed
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
new_json[questionIndex].title = target.value
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<ul>
|
||||
{options?.map(({ name }, optionIndex) => (
|
||||
<li key={optionIndex}>
|
||||
{/* Option input */}
|
||||
<input
|
||||
className="p-[5px] mb-1.5 text-[13px]"
|
||||
value={name}
|
||||
onChange={({ target }) => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].options[optionIndex].name = target.value
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
/>
|
||||
{/* Delete Option btn */}
|
||||
<a
|
||||
className="inline-block pl-px w-5 h-5 ml-[5px] leading-4 text-center text-gray-600 border border-gray-300 border-solid rounded-full cursor-pointer hover:no-underline hover:bg-gray-500 hover:border-transparent hover:text-white"
|
||||
onClick={() => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].options.splice(optionIndex, 1)
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{/* Add another option btn */}
|
||||
<a
|
||||
className="block pl-2 mt-1 mb-[14px] text-[13px] italic cursor-pointer"
|
||||
onClick={() => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].options.push({ name: '' })
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
>
|
||||
+ Add another option
|
||||
</a>
|
||||
|
||||
{/* Write-in Allowed toggle */}
|
||||
<li className={`${write_in_allowed ? '' : 'list-none'}`}>
|
||||
<span
|
||||
className={`inline-block w-32 pl-2 text-[13px] italic text-gray-800 ${
|
||||
!write_in_allowed ? 'opacity-60' : ''
|
||||
}`}
|
||||
>{`Write-in ${write_in_allowed ? 'Allowed' : 'Disabled'}`}</span>
|
||||
<Switch
|
||||
checked={write_in_allowed}
|
||||
label={''}
|
||||
onClick={() => {
|
||||
const new_json = [...json]
|
||||
new_json[questionIndex].write_in_allowed = !write_in_allowed
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
{/* "Add another question" btn */}
|
||||
<a
|
||||
className="block w-full p-2.5 italic bg-white my-[15px] text-[13px] cursor-pointer"
|
||||
className="block w-full p-2.5 italic bg-white my-[15px] text-[13px] cursor-pointer hover:bg-gray-50/80 hover:no-underline"
|
||||
onClick={() => {
|
||||
const new_json = [...(json || [])]
|
||||
const new_question_number = new_json.length + 1
|
||||
@@ -203,7 +250,7 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
|
||||
options: [{ name: 'Option 1' }, { name: 'Option 2' }],
|
||||
write_in_allowed: false,
|
||||
})
|
||||
setDesign(JSON.stringify(new_json, undefined, 2))
|
||||
saveDesign(new_json)
|
||||
}}
|
||||
>
|
||||
+ Add another question
|
||||
|
||||
43
src/admin/Conventions/AllYourConventions.tsx
Normal file
43
src/admin/Conventions/AllYourConventions.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Convention } from 'api/conventions/list-all-conventions'
|
||||
import Link from 'next/link'
|
||||
import { useReducer } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import TimeAgo from 'timeago-react'
|
||||
|
||||
export const AllYourConventions = () => {
|
||||
const [show, toggle] = useReducer((state) => !state, true)
|
||||
|
||||
const { data } = useSWR('/api/conventions/list-all-conventions', (url: string) =>
|
||||
fetch(url).then(async (r) => {
|
||||
if (!r.ok) throw await r.json()
|
||||
return await r.json()
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>
|
||||
Your Conventions: <span className="font-normal">{data?.conventions?.length}</span>{' '}
|
||||
{!!data?.conventions?.length && (
|
||||
<a className="text-[14px] font-normal ml-1 cursor-pointer" onClick={toggle}>
|
||||
[ {show ? '- Hide' : '+ Show'} ]
|
||||
</a>
|
||||
)}
|
||||
</h2>
|
||||
{show && (
|
||||
<ul>
|
||||
{data?.conventions?.map(({ convention_title, created_at, id }: Convention) => (
|
||||
<li key={id}>
|
||||
<Link href={`/admin/conventions/${id}`}>
|
||||
<a>
|
||||
<TimeAgo datetime={new Date(created_at._seconds * 1000)} />: {convention_title}
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<br />
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
36
src/admin/Conventions/ConventionsOverviewPage.tsx
Normal file
36
src/admin/Conventions/ConventionsOverviewPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { GlobalCSS } from 'src/GlobalCSS'
|
||||
import { Head } from 'src/Head'
|
||||
|
||||
import { useLoginRequired, useUser } from '../auth'
|
||||
import { HeaderBar } from '../HeaderBar'
|
||||
import { AllYourConventions } from './AllYourConventions'
|
||||
import { CreateNewConvention } from './CreateNewConvention'
|
||||
import { QRFigure } from './QRFigure'
|
||||
|
||||
export const ConventionsOverviewPage = () => {
|
||||
const { loading, loggedOut } = useUser()
|
||||
useLoginRequired(loggedOut)
|
||||
if (loading || loggedOut) return <p className="p-4 text-[21px]">Loading...</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Your Conventions" />
|
||||
|
||||
<HeaderBar />
|
||||
<main className="max-w-2xl p-4 mx-auto sm:px-8 overflow-clip">
|
||||
<div className="text-center">
|
||||
<h2>SIV Conventions</h2>
|
||||
<p>Create re-usable login credentials for voters to use across multiple ballots in a single day.</p>
|
||||
|
||||
<QRFigure className="mb-12" />
|
||||
</div>
|
||||
|
||||
<CreateNewConvention />
|
||||
|
||||
<AllYourConventions />
|
||||
</main>
|
||||
|
||||
<GlobalCSS />
|
||||
</>
|
||||
)
|
||||
}
|
||||
43
src/admin/Conventions/CreateNewConvention.tsx
Normal file
43
src/admin/Conventions/CreateNewConvention.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import router from 'next/router'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { api } from '../../api-helper'
|
||||
import { SaveButton } from '../SaveButton'
|
||||
|
||||
export const CreateNewConvention = () => {
|
||||
const [convention_title, set_title] = useState('')
|
||||
const $input = useRef<HTMLInputElement>(null)
|
||||
const $saveBtn = useRef<HTMLAnchorElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Create New Convention</h2>
|
||||
<label>Convention Title:</label>
|
||||
|
||||
<input
|
||||
className="w-full p-2 text-sm border border-gray-300 border-solid rounded"
|
||||
placeholder="Give your convention a name you will recognize"
|
||||
ref={$input}
|
||||
value={convention_title}
|
||||
onChange={(event) => set_title(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
$input.current?.blur()
|
||||
$saveBtn.current?.click()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SaveButton
|
||||
ref={$saveBtn}
|
||||
onPress={async () => {
|
||||
const response = await api('conventions/create-convention', { convention_title })
|
||||
if (response.status !== 201) throw await response.json()
|
||||
|
||||
// Set convention_id in URL
|
||||
const { convention_id } = await response.json()
|
||||
router.push(`${window.location.origin}/admin/conventions/${convention_id}`)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
42
src/admin/Conventions/CreateVoterCredentials.tsx
Normal file
42
src/admin/Conventions/CreateVoterCredentials.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { api } from 'src/api-helper'
|
||||
|
||||
import { SaveButton } from './SaveButton'
|
||||
import { revalidate } from './useConventionInfo'
|
||||
|
||||
export const CreateVoterCredentials = ({ convention_id }: { convention_id: string }) => {
|
||||
const [numQRs, setNumQRs] = useState<string>('')
|
||||
const $saveBtn = useRef<HTMLAnchorElement>(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label>Create how many voter QR credentials?</label>
|
||||
|
||||
<input
|
||||
className="w-20 ml-3 text-lg"
|
||||
min="1"
|
||||
placeholder="150"
|
||||
type="number"
|
||||
value={numQRs}
|
||||
onChange={(e) => setNumQRs(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
$saveBtn.current?.click()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SaveButton
|
||||
disabled={!numQRs}
|
||||
ref={$saveBtn}
|
||||
text="Create"
|
||||
onPress={async () => {
|
||||
await api(`/conventions/${convention_id}/create-qrs`, { numQRs: Number(numQRs) })
|
||||
revalidate(convention_id)
|
||||
setNumQRs('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/admin/Conventions/DownloadQRsPage.tsx
Normal file
65
src/admin/Conventions/DownloadQRsPage.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ConventionSet } from 'api/conventions/[convention_id]/download-set'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Head } from 'src/Head'
|
||||
import useSWR from 'swr'
|
||||
import TimeAgo from 'timeago-react'
|
||||
|
||||
import { QRCode } from './QRCode'
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
export const DownloadQRsPage = () => {
|
||||
const { c, set } = useRouter().query
|
||||
|
||||
// Download voters on page load
|
||||
const { data, error, isLoading } = useSWR(
|
||||
!c || !set ? null : `/api/conventions/${c}/download-set?set=${set}`,
|
||||
fetcher,
|
||||
)
|
||||
|
||||
// Handle error conditions
|
||||
if (!c || typeof c !== 'string') return <p className="p-4">Missing convention_id</p>
|
||||
if (!set || typeof set !== 'string') return <p className="p-4">Missing set</p>
|
||||
if (error) return <p className="p-4">Error: {JSON.stringify(error)}</p>
|
||||
|
||||
// console.log('swr ran:', { c, data, isLoading, set })
|
||||
if (!data || isLoading) return <p className="p-4">Loading...</p>
|
||||
|
||||
const { convention_title, qrs } = data as ConventionSet
|
||||
if (!qrs.length) return <p className="p-4">Empty set</p>
|
||||
|
||||
const createdAt = new Date(qrs[0].createdAt._seconds * 1000)
|
||||
|
||||
const sortedQrs = qrs.sort((a, b) => a.index - b.index)
|
||||
|
||||
return (
|
||||
<div className="p-4 overflow-auto">
|
||||
<Head title={`${qrs.length} for ${convention_title}`} />
|
||||
|
||||
{/* Header row */}
|
||||
<p className="flex justify-between">
|
||||
<span className="font-bold">Right Click {'→'} Print </span>
|
||||
|
||||
<span>
|
||||
{convention_title}: Set of {qrs.length}
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<span className="text-sm"> Created </span>
|
||||
{createdAt.toLocaleString()} <span className="text-sm opacity-60">({<TimeAgo datetime={createdAt} />})</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{/* Grid of QRs */}
|
||||
<div className="-mx-2.5">
|
||||
{sortedQrs.map(({ index, qr_id }, i) => (
|
||||
<div className="mx-2.5 my-1.5 text-center inline-block break-inside-avoid" key={i}>
|
||||
<span className="text-sm opacity-50">{qr_id}</span>
|
||||
<QRCode {...{ convention_id: c, qr_id }} />
|
||||
<span>{index}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
src/admin/Conventions/ListOfQRSets.tsx
Normal file
35
src/admin/Conventions/ListOfQRSets.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import Link from 'next/link'
|
||||
import TimeAgo from 'timeago-react'
|
||||
|
||||
import { useConventionInfo } from './useConventionInfo'
|
||||
|
||||
export const ListOfQRSets = () => {
|
||||
const { id, qrs } = useConventionInfo()
|
||||
|
||||
return (
|
||||
<ol className="inset-0 pl-6 mt-0 ml-0">
|
||||
{qrs?.map(({ createdAt, number }, i) => (
|
||||
<li key={i}>
|
||||
{/* Size */}
|
||||
<span className="inline-block w-20">Set of {number} </span>
|
||||
|
||||
{/* Download */}
|
||||
<Link href={`/admin/conventions/download?c=${id}&set=${i}`} target="_blank">
|
||||
<a className="pl-1" target="_blank">
|
||||
Download
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
{/* Time */}
|
||||
<span className="inline-block w-32 text-[13px] text-right opacity-60">
|
||||
{+new Date() - +new Date(createdAt._seconds * 1000) < 60 * 1000 ? (
|
||||
'Just now'
|
||||
) : (
|
||||
<TimeAgo datetime={new Date(createdAt._seconds * 1000)} />
|
||||
)}{' '}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
52
src/admin/Conventions/ManageConventionPage.tsx
Normal file
52
src/admin/Conventions/ManageConventionPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import Link from 'next/link'
|
||||
import { GlobalCSS } from 'src/GlobalCSS'
|
||||
import { Head } from 'src/Head'
|
||||
|
||||
import { useLoginRequired, useUser } from '../auth'
|
||||
import { HeaderBar } from '../HeaderBar'
|
||||
import { CreateVoterCredentials } from './CreateVoterCredentials'
|
||||
import { ListOfQRSets } from './ListOfQRSets'
|
||||
import { QRFigure } from './QRFigure'
|
||||
import { SetRedirection } from './SetRedirection'
|
||||
import { useConventionID } from './useConventionID'
|
||||
import { useConventionInfo } from './useConventionInfo'
|
||||
|
||||
export const ManageConventionPage = () => {
|
||||
const { convention_id } = useConventionID()
|
||||
const { convention_title } = useConventionInfo()
|
||||
|
||||
const { loading, loggedOut } = useUser()
|
||||
useLoginRequired(loggedOut)
|
||||
if (loading || loggedOut || !convention_title) return <p className="p-4 text-[21px]">Loading...</p>
|
||||
if (!convention_id) return <p>Convention ID error</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={`Manage${convention_title ? `: ${convention_title}` : ' Convention'}`} />
|
||||
|
||||
<HeaderBar />
|
||||
<main className="p-4 sm:px-8 overflow-clip">
|
||||
{/* Back link */}
|
||||
<Link href="/admin/conventions">
|
||||
<a className="block mt-2 transition opacity-60 hover:opacity-100">← Back to all Conventions</a>
|
||||
</Link>
|
||||
|
||||
{/* Title */}
|
||||
<h2>Manage: {convention_title}</h2>
|
||||
|
||||
{/* Set # voters */}
|
||||
<CreateVoterCredentials {...{ convention_id }} />
|
||||
|
||||
{/* List of voter sets */}
|
||||
<ListOfQRSets />
|
||||
|
||||
<QRFigure {...{ convention_id }} className="mt-12 -ml-5" />
|
||||
|
||||
{/* Set redirection */}
|
||||
<SetRedirection />
|
||||
</main>
|
||||
|
||||
<GlobalCSS />
|
||||
</>
|
||||
)
|
||||
}
|
||||
38
src/admin/Conventions/QRCode.tsx
Normal file
38
src/admin/Conventions/QRCode.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Options } from 'qr-code-styling'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const qrOptions: Options = {
|
||||
cornersSquareOptions: { color: '#000000', type: 'square' },
|
||||
dotsOptions: { type: 'classy-rounded' },
|
||||
height: 111,
|
||||
image: '/VOTE.png',
|
||||
imageOptions: { hideBackgroundDots: true, imageSize: 0.5, margin: 0 },
|
||||
qrOptions: { errorCorrectionLevel: 'H' },
|
||||
width: 111,
|
||||
}
|
||||
|
||||
export const QRCode = ({
|
||||
className,
|
||||
convention_id = ':conv_id',
|
||||
qr_id = ':qr_id',
|
||||
}: {
|
||||
className?: string
|
||||
convention_id?: string
|
||||
qr_id?: string
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return // Client-side only
|
||||
|
||||
import('qr-code-styling').then(({ default: QRCodeStyling }) => {
|
||||
if (!ref.current) return console.warn('Missing QR container ref')
|
||||
const customData = `${window.location.origin}/c/${convention_id}/${qr_id}`
|
||||
const qrCode = new QRCodeStyling({ ...qrOptions, data: customData })
|
||||
if (ref.current.firstChild) ref.current.removeChild(ref.current.firstChild)
|
||||
qrCode.append(ref.current)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <div {...{ className, ref }} />
|
||||
}
|
||||
22
src/admin/Conventions/QRFigure.tsx
Normal file
22
src/admin/Conventions/QRFigure.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { QRCode } from './QRCode'
|
||||
|
||||
export const QRFigure = ({ className, convention_id = ':conv_id' }: { className?: string; convention_id?: string }) => (
|
||||
<figure className={`mx-0 inline-block ${className}`}>
|
||||
<div className="flex items-center">
|
||||
{/* Left of arrow */}
|
||||
<div className="text-center">
|
||||
<QRCode {...{ convention_id }} className="relative scale-75 top-3" />
|
||||
<span className="text-xs opacity-70">QR code</span>
|
||||
</div>
|
||||
{/* Arrow */}
|
||||
<i
|
||||
className="pl-3 pr-6 text-[30px] opacity-80"
|
||||
style={{ fontFamily: '"Proxima Nova", "Helvetica Neue", Helvetica, Arial, sans-serif' }}
|
||||
>
|
||||
{'→'}
|
||||
</i>{' '}
|
||||
{/* Right of Arrow */}
|
||||
<i className="relative top-0.5 overflow-auto break-words">siv.org/c/{convention_id}/:qr_id</i>
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
41
src/admin/Conventions/SaveButton.tsx
Normal file
41
src/admin/Conventions/SaveButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { forwardRef, useState } from 'react'
|
||||
|
||||
import { OnClickButton } from '../../_shared/Button'
|
||||
import { Spinner } from '../Spinner'
|
||||
|
||||
type SaveButtonProps = {
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
onPress: () => Promise<void>
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const SaveButton = forwardRef<HTMLAnchorElement, SaveButtonProps>(
|
||||
({ disabled, id, onPress, text }: SaveButtonProps, ref) => {
|
||||
const [pending, set_pending] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<OnClickButton
|
||||
id={id}
|
||||
ref={ref}
|
||||
style={{ marginRight: 0, padding: '8px 17px' }}
|
||||
onClick={async () => {
|
||||
set_pending(true)
|
||||
await onPress()
|
||||
set_pending(false)
|
||||
}}
|
||||
{...{ disabled }}
|
||||
>
|
||||
{!pending ? (
|
||||
text || 'Save'
|
||||
) : (
|
||||
<>
|
||||
<Spinner /> Saving...
|
||||
</>
|
||||
)}
|
||||
</OnClickButton>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
SaveButton.displayName = 'SaveButton'
|
||||
168
src/admin/Conventions/SetRedirection.tsx
Normal file
168
src/admin/Conventions/SetRedirection.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Election } from 'api/admin-all-elections'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from 'src/api-helper'
|
||||
import TimeAgo from 'timeago-react'
|
||||
|
||||
import { useAllYourElections } from '../AllYourElections'
|
||||
import { revalidate, useConventionInfo } from './useConventionInfo'
|
||||
|
||||
export const SetRedirection = () => {
|
||||
const { data } = useAllYourElections()
|
||||
const [filteredData, setFilteredData] = useState<Election[]>([])
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { active_redirect, id: convention_id } = useConventionInfo()
|
||||
|
||||
const { elections } = (data as { elections: Election[] }) || {}
|
||||
|
||||
// Filter list based on search text
|
||||
useEffect(() => {
|
||||
if (!elections) return
|
||||
|
||||
const filtered = elections.filter((item) => item.election_title.toLowerCase().includes(searchText.toLowerCase()))
|
||||
setFilteredData(filtered)
|
||||
}, [searchText, elections])
|
||||
|
||||
if (!elections) return <p>Loading elections...</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Redirect your convention QRs to which of your ballots?</h3>
|
||||
|
||||
<table className="mt-1 h-64 px-1.5 py-2 mb-4 overflow-y-scroll rounded-lg shadow-inner bg-neutral-50">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={7}>
|
||||
{/* Set filter textbox */}
|
||||
<input
|
||||
className="w-full px-3 text-[15px] py-2 text-gray-700 border rounded shadow"
|
||||
placeholder="Filter ballot by title"
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
|
||||
{!filteredData.length && <p className="italic opacity-70">No elections found</p>}
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
{/* Table header */}
|
||||
<tr className="text-[12px] opacity-60 [&>*]:p-1 [&>*]:py-2">
|
||||
<th />
|
||||
<th className="text-left">Ballot name</th>
|
||||
<th>Created</th>
|
||||
<th>Voters</th>
|
||||
<th>Votes Cast</th>
|
||||
<th className="relative bottom-px">Finalized?</th>
|
||||
<th>Redirect</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Filtered list */}
|
||||
{filteredData.map((e, i) => {
|
||||
const is_active_redirect = e.id === active_redirect
|
||||
return (
|
||||
// Each row
|
||||
<tr
|
||||
className={`text-center py-1 px-1.5 rounded hover:bg-gray-200 group [&>td]:px-1 ${
|
||||
is_active_redirect ? 'bg-blue-800/20 hover:!bg-blue-800/30' : ''
|
||||
}`}
|
||||
key={e.id}
|
||||
>
|
||||
{/* # */}
|
||||
<td className="opacity-50 text-[11px]">{i + 1}</td>
|
||||
|
||||
{/* Ballot name */}
|
||||
<td className="max-w-[300px] text-left">
|
||||
<a
|
||||
className="text-black/90 hover:text-blue-700"
|
||||
href={`/admin/${e.id}/voters`}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{e.election_title}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
{/* Created */}
|
||||
<td className="opacity-50 text-[11px]">
|
||||
<TimeAgo datetime={new Date(e.created_at._seconds * 1000)} />
|
||||
</td>
|
||||
|
||||
{/* Voters */}
|
||||
<td className={!e.num_voters ? 'opacity-30' : ''}>{e.num_voters}</td>
|
||||
|
||||
{/* Votes */}
|
||||
<td className={!e.num_votes ? 'opacity-30' : ''}>{e.num_votes}</td>
|
||||
|
||||
{/* Finalized? */}
|
||||
<td className="!px-2">
|
||||
<input disabled checked={e.ballot_design_finalized && !!e.threshold_public_key} type="checkbox" />
|
||||
|
||||
{/* Hints if active but not finalized */}
|
||||
{is_active_redirect && (
|
||||
<>
|
||||
{/* Ballot not finalized */}
|
||||
{!e.ballot_design_finalized ? (
|
||||
<div>
|
||||
<span className="text-[8px] relative bottom-0.5 opacity-70 hover:no-underline">❌ </span>
|
||||
<a
|
||||
className="text-black/60 hover:text-blue-700"
|
||||
href={`/admin/${e.id}/ballot-design`}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Ballot design not finalized
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{/* Observers not set */}
|
||||
{!e.threshold_public_key ? (
|
||||
<div className="text-left">
|
||||
<span className="text-[8px] relative bottom-0.5 opacity-70">❌ </span>
|
||||
<a
|
||||
className="text-black/60 hover:text-blue-700"
|
||||
href={`/admin/${e.id}/observers`}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Observers not set
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Redirect: Set / Active */}
|
||||
<td
|
||||
className={`text-xs cursor-pointer group-hover:opacity-60 ${
|
||||
is_active_redirect ? '!opacity-60' : 'opacity-0'
|
||||
} hover:!opacity-80 hover:bg-blue-800/30`}
|
||||
onClick={async () =>
|
||||
confirm(
|
||||
`${!is_active_redirect ? `Redirect QRs to '${e.election_title}'?` : 'Remove redirection?'}`,
|
||||
) &&
|
||||
(await api(`/conventions/${convention_id}/set-redirect`, {
|
||||
election_id: !is_active_redirect ? e.id : '',
|
||||
})) &&
|
||||
revalidate(convention_id || '')
|
||||
}
|
||||
>
|
||||
{is_active_redirect ? (
|
||||
'Active'
|
||||
) : (
|
||||
<span className="px-3 bg-white border border-solid rounded-lg border-black/50">Set</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/admin/Conventions/YourConventionsLink.tsx
Normal file
23
src/admin/Conventions/YourConventionsLink.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export const YourConventionsLink = () => {
|
||||
return (
|
||||
<Link href="/admin/conventions">
|
||||
<div className="pb-2 pl-2 pr-4 -ml-2 transition border-2 border-transparent border-solid rounded-lg cursor-pointer sm:pl-4 sm:-ml-4 hover:border-[#002868] mt-14">
|
||||
<h2>
|
||||
Your Conventions <BetaBadge />
|
||||
</h2>
|
||||
<p>
|
||||
Reusable voter credentials, for multiple ballots throughout a day.{' '}
|
||||
<a className="ml-3 opacity-70 hover:no-underline">Go {'→'}</a>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const BetaBadge = () => (
|
||||
<span className="text-[11px] border-solid border border-[#002868]/70 relative bottom-[3px] text-[#002868]/90 font-bold p-1 rounded ml-1">
|
||||
Beta
|
||||
</span>
|
||||
)
|
||||
10
src/admin/Conventions/useConventionID.tsx
Normal file
10
src/admin/Conventions/useConventionID.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
/** Grabs `convention_id` from the router. Blocks `string[]` type */
|
||||
export const useConventionID = (): { convention_id?: string } => {
|
||||
const { convention_id } = useRouter().query
|
||||
|
||||
if (Array.isArray(convention_id)) return {}
|
||||
|
||||
return { convention_id }
|
||||
}
|
||||
20
src/admin/Conventions/useConventionInfo.ts
Normal file
20
src/admin/Conventions/useConventionInfo.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Convention } from 'api/validate-admin-jwt'
|
||||
import { useRouter } from 'next/router'
|
||||
import useSWR, { mutate } from 'swr'
|
||||
|
||||
import { fetcher } from '../AllYourElections'
|
||||
|
||||
export const useConventionInfo = () => {
|
||||
const { convention_id } = useRouter().query
|
||||
|
||||
const { data }: { data?: Convention } = useSWR(
|
||||
!convention_id ? null : `/api/conventions/${convention_id}/load-convention-admin`,
|
||||
fetcher,
|
||||
)
|
||||
|
||||
return { ...data }
|
||||
}
|
||||
|
||||
export function revalidate(convention_id: string) {
|
||||
mutate(`/api/conventions/${convention_id}/load-convention-admin`)
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
import router from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { api } from '../api-helper'
|
||||
import { SaveButton } from './SaveButton'
|
||||
|
||||
export const CreateNewElection = () => {
|
||||
const [election_title, set_title] = useState('')
|
||||
const $input = useRef<HTMLInputElement>(null)
|
||||
const $saveBtn = useRef<HTMLAnchorElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Create New Election</h2>
|
||||
<label>Election Title:</label>
|
||||
<h2>Create New Ballot</h2>
|
||||
<label>Ballot Title:</label>
|
||||
|
||||
<input
|
||||
className="w-full p-2 text-sm border border-gray-300 border-solid rounded"
|
||||
id="election-title"
|
||||
placeholder="Give your election a name your voters will recognize"
|
||||
placeholder="Give your ballot a name your voters will recognize"
|
||||
value={election_title}
|
||||
onChange={(event) => set_title(event.target.value)}
|
||||
onKeyPress={(event) => {
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
document.getElementById('election-title')?.blur()
|
||||
document.getElementById('election-title-save')?.click()
|
||||
$input.current?.blur()
|
||||
$saveBtn.current?.click()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -28,27 +31,13 @@ export const CreateNewElection = () => {
|
||||
id="election-title-save"
|
||||
onPress={async () => {
|
||||
const response = await api('create-election', { election_title })
|
||||
if (response.status !== 201) throw await response.json()
|
||||
|
||||
if (response.status === 201) {
|
||||
const { election_id } = await response.json()
|
||||
|
||||
// Set election_id in URL
|
||||
router.push(`${window.location.origin}/admin/${election_id}/ballot-design`)
|
||||
} else {
|
||||
throw await response.json()
|
||||
}
|
||||
// Set election_id in URL
|
||||
const { election_id } = await response.json()
|
||||
router.push(`${window.location.origin}/admin/${election_id}/ballot-design`)
|
||||
}}
|
||||
/>
|
||||
|
||||
<style jsx>{`
|
||||
input {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
|
||||
import { TextField, TextFieldProps } from '@mui/material'
|
||||
import { validate as validateEmail } from 'email-validator'
|
||||
import router from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { OnClickButton, darkBlue } from 'src/_shared/Button'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
import { api } from 'src/api-helper'
|
||||
import { Row } from 'src/homepage/AreYouAVoter'
|
||||
|
||||
import { CreatedAccountWaiting } from './CreatedAccountWaiting'
|
||||
import { breakpoint } from './LoginPage'
|
||||
@@ -99,20 +101,6 @@ export const CreateAccount = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const Row = (props: BoxProps) => (
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
margin: '1.5rem 0',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const Field = (props: TextFieldProps & { label: string }) => (
|
||||
<TextField
|
||||
fullWidth
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TextField } from '@material-ui/core'
|
||||
import { TextField } from '@mui/material'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { OnClickButton } from 'src/_shared/Button'
|
||||
@@ -129,5 +129,5 @@ export const EnterCodePage = () => {
|
||||
`}</style>
|
||||
<GlobalCSS />
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TextField } from '@material-ui/core'
|
||||
import { TextField } from '@mui/material'
|
||||
import { validate as validateEmail } from 'email-validator'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ChangeEventHandler, KeyboardEventHandler, useRef, useState } from 'react'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'
|
||||
import SwipeableDrawer from '@mui/material/SwipeableDrawer'
|
||||
import { startCase } from 'lodash-es'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EditOutlined } from '@ant-design/icons'
|
||||
import { TextField } from '@material-ui/core'
|
||||
import { TextField } from '@mui/material'
|
||||
import { validate as validateEmail } from 'email-validator'
|
||||
import { useState } from 'react'
|
||||
|
||||
|
||||
@@ -1,48 +1,43 @@
|
||||
import { useState } from 'react'
|
||||
import { forwardRef, useState } from 'react'
|
||||
|
||||
import { OnClickButton } from '../_shared/Button'
|
||||
import { Spinner } from './Spinner'
|
||||
|
||||
export const SaveButton = ({
|
||||
disabled,
|
||||
id,
|
||||
onPress,
|
||||
text,
|
||||
}: {
|
||||
type SaveButtonProps = {
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
onPress: () => Promise<void>
|
||||
text?: string
|
||||
}) => {
|
||||
const [pending, set_pending] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<div className="right-aligned">
|
||||
<OnClickButton
|
||||
id={id}
|
||||
style={{ marginRight: 0, padding: '8px 17px' }}
|
||||
onClick={async () => {
|
||||
set_pending(true)
|
||||
await onPress()
|
||||
set_pending(false)
|
||||
}}
|
||||
{...{ disabled }}
|
||||
>
|
||||
{!pending ? (
|
||||
text || 'Save'
|
||||
) : (
|
||||
<>
|
||||
<Spinner /> Saving...
|
||||
</>
|
||||
)}
|
||||
</OnClickButton>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.right-aligned {
|
||||
text-align: right;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const SaveButton = forwardRef<HTMLAnchorElement, SaveButtonProps>(
|
||||
({ disabled, id, onPress, text }: SaveButtonProps, ref) => {
|
||||
const [pending, set_pending] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<div className="text-right">
|
||||
<OnClickButton
|
||||
id={id}
|
||||
ref={ref}
|
||||
style={{ marginRight: 0, padding: '8px 17px' }}
|
||||
onClick={async () => {
|
||||
set_pending(true)
|
||||
await onPress()
|
||||
set_pending(false)
|
||||
}}
|
||||
{...{ disabled }}
|
||||
>
|
||||
{!pending ? (
|
||||
text || 'Save'
|
||||
) : (
|
||||
<>
|
||||
<Spinner /> Saving...
|
||||
</>
|
||||
)}
|
||||
</OnClickButton>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
SaveButton.displayName = 'SaveButton'
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Tooltip } from './Tooltip'
|
||||
|
||||
export const AcceptedCell = ({ accepted }: { accepted?: unknown[] }) => {
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={
|
||||
accepted ? (
|
||||
<>
|
||||
{(accepted as {
|
||||
id: string
|
||||
timestamp: number
|
||||
}[])?.map((event) => (
|
||||
<div key={event.id} style={{ fontSize: 14 }}>
|
||||
{new Date(event.timestamp * 1000).toLocaleString()}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
>
|
||||
<td style={{ textAlign: 'center' }}>{accepted?.length}</td>
|
||||
</Tooltip>
|
||||
<style jsx>{`
|
||||
td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 3px 10px;
|
||||
margin: 0;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useState } from 'react'
|
||||
import { api } from '../../api-helper'
|
||||
import { SaveButton } from '../SaveButton'
|
||||
import { revalidate, useStored } from '../useStored'
|
||||
import { AddVoterTextarea } from './AddVotersTextarea'
|
||||
import { ExistingVoters } from './ExistingVoters'
|
||||
import { MultilineInput } from './MultilineInput'
|
||||
import { RequestEsignatures } from './RequestEsignatures'
|
||||
import { ToggleRegistration } from './ToggleRegistration'
|
||||
|
||||
@@ -16,7 +16,7 @@ export const AddVoters = () => {
|
||||
<div className="max-w-[50rem]">
|
||||
<h2 className="hidden sm:block">Voters</h2>
|
||||
<h4>Add new voters by email address:</h4>
|
||||
<MultilineInput state={new_voters} update={set_new_voters} />
|
||||
<AddVoterTextarea state={new_voters} update={set_new_voters} />
|
||||
|
||||
{/* Show save button if there are new voters to add */}
|
||||
{new_voters === '' ? (
|
||||
|
||||
44
src/admin/Voters/AddVotersCodeMirror.tsx
Normal file
44
src/admin/Voters/AddVotersCodeMirror.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { zebraStripes } from '@uiw/codemirror-extensions-zebra-stripes'
|
||||
import { createTheme } from '@uiw/codemirror-themes'
|
||||
import CodeMirror, { ReactCodeMirrorProps } from '@uiw/react-codemirror'
|
||||
|
||||
const theme = createTheme({
|
||||
settings: {
|
||||
caret: '#0070f3',
|
||||
fontFamily: 'sans-serif',
|
||||
foreground: '#444',
|
||||
gutterBackground: '#fff',
|
||||
gutterForeground: '#999',
|
||||
selectionMatch: '#036dd626',
|
||||
},
|
||||
styles: [],
|
||||
theme: 'light',
|
||||
})
|
||||
|
||||
const CodeMirrorComponent = (props: ReactCodeMirrorProps) => (
|
||||
<>
|
||||
<CodeMirror
|
||||
{...props}
|
||||
autoFocus
|
||||
basicSetup={{ highlightActiveLine: false, highlightActiveLineGutter: false, indentOnInput: false }}
|
||||
extensions={[zebraStripes({ step: 2 })]}
|
||||
theme={theme}
|
||||
/>
|
||||
<style global jsx>{`
|
||||
.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
.cm-editor {
|
||||
font-size: 15px;
|
||||
}
|
||||
.cm-zebra-stripe {
|
||||
background-color: #8881 !important;
|
||||
}
|
||||
.cm-cursor {
|
||||
border-left-width: 2px !important;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
|
||||
export default CodeMirrorComponent
|
||||
15
src/admin/Voters/AddVotersTextarea.tsx
Normal file
15
src/admin/Voters/AddVotersTextarea.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const CodeMirror = dynamic(() => import('./AddVotersCodeMirror'), { ssr: false })
|
||||
|
||||
export const AddVoterTextarea = ({ state, update }: { state: string; update: (s: string) => void }) => {
|
||||
return (
|
||||
<CodeMirror
|
||||
height="auto"
|
||||
maxHeight="300px"
|
||||
style={{ border: '1px solid #ccc', borderRadius: 3 }}
|
||||
value={state}
|
||||
onChange={(value) => update(value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -14,9 +14,8 @@ export const DeliveriesAndFailures = ({
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
interactive={!!failed}
|
||||
placement="top"
|
||||
title={
|
||||
tooltip={
|
||||
failed || deliveries ? (
|
||||
<>
|
||||
{(
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
|
||||
export const MultilineInput = ({
|
||||
placeholder,
|
||||
startAt = 1,
|
||||
state,
|
||||
update,
|
||||
}: {
|
||||
placeholder?: string
|
||||
startAt?: number
|
||||
state: string
|
||||
update: Dispatch<SetStateAction<string>>
|
||||
}) => {
|
||||
return (
|
||||
<div className="editor-container">
|
||||
<Editor
|
||||
autoFocus
|
||||
className="editor"
|
||||
highlight={(code) =>
|
||||
code
|
||||
.split('\n')
|
||||
.map((line, i) => `<span class='editorLineNumber'>${i + startAt}</span>${line}`)
|
||||
.join('\n')
|
||||
}
|
||||
placeholder={placeholder}
|
||||
textareaId="textarea"
|
||||
value={state}
|
||||
onValueChange={(v) => update(v)}
|
||||
/>
|
||||
<style global jsx>{`
|
||||
.editor-container {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 3px;
|
||||
margin-top: 5px;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.editor {
|
||||
/* Striped background */
|
||||
line-height: 20px;
|
||||
background-image: linear-gradient(#fff 50%, #f6f6f6 50%);
|
||||
background-size: 100% 40px; /* 2x line-height */
|
||||
background-position: left 0px;
|
||||
}
|
||||
|
||||
.editor #textarea,
|
||||
.editor pre {
|
||||
padding-left: 46px !important;
|
||||
}
|
||||
|
||||
.editor .editorLineNumber {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
opacity: 0.6;
|
||||
text-align: right;
|
||||
width: 28px;
|
||||
font-weight: 200;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,12 +5,14 @@ export const QueuedCell = ({ invite_queued }: { invite_queued?: unknown[] }) =>
|
||||
<>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={
|
||||
tooltip={
|
||||
invite_queued ? (
|
||||
<>
|
||||
{(invite_queued as {
|
||||
time: { _seconds: number }
|
||||
}[])?.map((event, index) => (
|
||||
{(
|
||||
invite_queued as {
|
||||
time: { _seconds: number }
|
||||
}[]
|
||||
)?.map((event, index) => (
|
||||
<div key={index} style={{ fontSize: 14 }}>
|
||||
{new Date(event.time._seconds * 1000).toLocaleString()}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Switch } from '@material-ui/core'
|
||||
import Image from 'next/image'
|
||||
import esignatureIcon from 'public/esignature.png'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { api } from '../../api-helper'
|
||||
import { Switch } from '../BallotDesign/Switch'
|
||||
import { Spinner } from '../Spinner'
|
||||
import { revalidate, useStored } from '../useStored'
|
||||
|
||||
@@ -26,30 +26,17 @@ export const RequestEsignatures = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<label onClick={toggleESignature}>
|
||||
<div style={{ display: 'inline-block', marginRight: 5, position: 'relative', top: 2 }}>
|
||||
<div className="mb-8">
|
||||
<label className="cursor-pointer" onClick={toggleESignature}>
|
||||
<span className="mr-1.5 relative top-0.5">
|
||||
<Image height={(218 / 700) * 70} layout="fixed" src={esignatureIcon} width={70} />
|
||||
</div>
|
||||
Request <i>eSignatures</i>?
|
||||
</span>
|
||||
Request eSignatures?
|
||||
</label>
|
||||
<div style={{ bottom: 3, display: 'inline-block', left: 10, position: 'relative' }}>
|
||||
<Switch checked={!!esignature_requested} color="primary" onClick={toggleESignature} />
|
||||
</div>
|
||||
<span className="relative bottom-[3px] ml-3">
|
||||
<Switch checked={!!esignature_requested} label="" onClick={toggleESignature} />
|
||||
</span>
|
||||
{updating && <Spinner />}
|
||||
<style jsx>{`
|
||||
section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
i {
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useState } from 'react'
|
||||
import { ReviewLog } from 'api/election/[election_id]/admin/load-admin'
|
||||
|
||||
import { ReviewLog } from '../../../pages/api/election/[election_id]/admin/load-admin'
|
||||
import { api } from '../../api-helper'
|
||||
import { Tooltip } from './Tooltip'
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
customWidth: {
|
||||
width: 300,
|
||||
},
|
||||
}))
|
||||
|
||||
export const getStatus = (esignature_review?: ReviewLog[]) =>
|
||||
esignature_review ? esignature_review[esignature_review.length - 1]?.review : undefined
|
||||
|
||||
@@ -25,102 +17,47 @@ export const Signature = ({
|
||||
esignature?: string
|
||||
esignature_review?: ReviewLog[]
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const storeReview = (review: 'approve' | 'reject' | 'pending') => async () => {
|
||||
await api(`election/${election_id}/admin/review-signature`, {
|
||||
const storeReview = (review: 'approve' | 'reject' | 'pending', setIsShown: (setting: boolean) => void) => async () =>
|
||||
(await api(`election/${election_id}/admin/review-signature`, {
|
||||
emails: [email],
|
||||
review,
|
||||
})
|
||||
setOpen(false)
|
||||
}
|
||||
})) && setIsShown(false)
|
||||
|
||||
const status = getStatus(esignature_review)
|
||||
|
||||
return (
|
||||
<td>
|
||||
<td className="px-0.5 hover:bg-black/5 cursor-pointer" style={{ border: '1px solid #ccc' }}>
|
||||
<Tooltip
|
||||
interactive
|
||||
className={classes.customWidth}
|
||||
className="w-72"
|
||||
enterDelay={200}
|
||||
open={open}
|
||||
leaveDelay={200}
|
||||
placement="top"
|
||||
title={
|
||||
<div className="tooltip">
|
||||
{esignature ? <img src={esignature} /> : <p>Signature missing</p>}
|
||||
<div className="row">
|
||||
tooltip={({ setIsShown }: { setIsShown: (setting: boolean) => void }) => (
|
||||
<div>
|
||||
{esignature ? <img className="max-w-[280px]" src={esignature} /> : <p>Signature missing</p>}
|
||||
<div className="flex justify-between">
|
||||
<a
|
||||
className={status === 'reject' ? 'bold' : ''}
|
||||
onClick={storeReview(status === 'reject' ? 'pending' : 'reject')}
|
||||
className={`cursor-pointer ${status === 'reject' ? 'font-bold' : ''}`}
|
||||
onClick={storeReview(status === 'reject' ? 'pending' : 'reject', setIsShown)}
|
||||
>
|
||||
❌ Reject{status === 'reject' ? 'ed' : ''}
|
||||
</a>
|
||||
<a
|
||||
className={status === 'approve' ? 'bold' : ''}
|
||||
onClick={storeReview(status === 'approve' ? 'pending' : 'approve')}
|
||||
className={`cursor-pointer ${status === 'approve' ? 'font-bold' : ''}`}
|
||||
onClick={storeReview(status === 'approve' ? 'pending' : 'approve', setIsShown)}
|
||||
>
|
||||
✅ Approve{status === 'approve' ? 'd' : ''}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onClick={() => setOpen(!open)}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpen={() => ['approve', 'reject'].includes(status || '') || setOpen(true)}
|
||||
)}
|
||||
>
|
||||
<img className={`small ${status || ''}`} src={esignature} />
|
||||
<img
|
||||
className="min-h-5 min-h-[20px] max-w-[100px] -mb-1.5 overflow-hidden"
|
||||
src={esignature}
|
||||
style={{ border: `2px solid ${status === 'approve' ? 'green' : status === 'reject' ? 'red' : '#fff0'}` }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<style jsx>{`
|
||||
td {
|
||||
border: 1px solid #ccc;
|
||||
margin: 0;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
td:hover {
|
||||
cursor: pointer;
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
img.small {
|
||||
min-height: 20px;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #fff0;
|
||||
margin-bottom: -6px;
|
||||
}
|
||||
|
||||
img.small.approve {
|
||||
border-color: green;
|
||||
}
|
||||
|
||||
img.small.reject {
|
||||
border-color: red;
|
||||
}
|
||||
|
||||
.tooltip img {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tooltip a {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tooltip a:first-child {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
`}</style>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,128 @@
|
||||
import { Tooltip as MUITooltip, TooltipProps } from '@material-ui/core'
|
||||
import { withStyles } from '@material-ui/core/styles'
|
||||
import React, { Fragment, ReactElement, ReactNode, cloneElement, useEffect, useRef, useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { CSSTransition } from 'react-transition-group'
|
||||
|
||||
const LightTooltip = withStyles(() => ({
|
||||
tooltip: {
|
||||
backgroundColor: '#fffe',
|
||||
boxShadow: '0px 1px 3px #0006',
|
||||
color: '#222',
|
||||
padding: '5px 10px',
|
||||
},
|
||||
}))(MUITooltip)
|
||||
|
||||
export const Tooltip = (props: TooltipProps) => {
|
||||
return <LightTooltip {...props} />
|
||||
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||
interface TooltipProps {
|
||||
children: ReactElement
|
||||
className?: string
|
||||
enterDelay?: number
|
||||
leaveDelay?: number
|
||||
placement?: Placement
|
||||
tooltip: string | ReactNode | (({ setIsShown }: { setIsShown: (setting: boolean) => void }) => ReactNode)
|
||||
}
|
||||
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
className = '',
|
||||
enterDelay = 0,
|
||||
leaveDelay = 50,
|
||||
placement = 'top',
|
||||
tooltip,
|
||||
}: TooltipProps) => {
|
||||
const [isShown, setIsShown] = useState(false)
|
||||
const [tooltipPos, setTooltipPos] = useState({ left: 0, top: 0 })
|
||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||
const targetRef = useRef<HTMLElement>(null)
|
||||
const closeTimeoutId = useRef<number | null>(null)
|
||||
|
||||
// Calc position whenever shown
|
||||
useEffect(() => {
|
||||
if (isShown) updateTooltipPosition()
|
||||
}, [isShown, placement])
|
||||
|
||||
function onMouseEnter() {
|
||||
closeTimeoutId.current && clearTimeout(closeTimeoutId.current)
|
||||
setTimeout(() => setIsShown(true), enterDelay)
|
||||
}
|
||||
function onMouseLeave() {
|
||||
// Setup a delay for leaving, but allow cancellation if re-entering
|
||||
closeTimeoutId.current = setTimeout(() => {
|
||||
setIsShown(false)
|
||||
}, leaveDelay) as unknown as number
|
||||
}
|
||||
|
||||
// Clone the child and inject props
|
||||
const child = cloneElement(children, {
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
ref: targetRef,
|
||||
})
|
||||
|
||||
function updateTooltipPosition() {
|
||||
if (!tooltipRef.current) return console.warn('Missing tooltip ref')
|
||||
if (!targetRef.current) return console.warn('Missing target ref')
|
||||
|
||||
const targetRect = targetRef.current.getBoundingClientRect()
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect()
|
||||
const newPosition = calculatePosition(targetRect, tooltipRect, placement)
|
||||
setTooltipPos(newPosition)
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{child}
|
||||
|
||||
{/* Tooltip element */}
|
||||
{tooltip &&
|
||||
ReactDOM.createPortal(
|
||||
<CSSTransition unmountOnExit classNames="tooltip" in={isShown} timeout={10}>
|
||||
<div
|
||||
className={`bg-white/90 rounded p-1 fixed z-50 ${className}`}
|
||||
ref={tooltipRef}
|
||||
{...{ onMouseEnter, onMouseLeave }}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
boxShadow: '0px 1px 3px rgba(0, 0, 0, 0.4)',
|
||||
left: tooltipPos.left,
|
||||
top: tooltipPos.top,
|
||||
}}
|
||||
>
|
||||
{typeof tooltip === 'function' ? tooltip({ setIsShown }) : tooltip}
|
||||
</div>
|
||||
</CSSTransition>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Transitions */}
|
||||
<style global jsx>{`
|
||||
.tooltip-enter,
|
||||
.tooltip-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(7px);
|
||||
}
|
||||
|
||||
.tooltip-enter-active,
|
||||
.tooltip-exit {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tooltip-enter-active {
|
||||
transition: opacity 200ms, transform 100ms;
|
||||
}
|
||||
`}</style>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
function calculatePosition(
|
||||
{ height, left, top, width }: { height: number; left: number; top: number; width: number },
|
||||
{ height: tooltipHeight, width: tooltipWidth }: { height: number; width: number },
|
||||
placement: Placement,
|
||||
) {
|
||||
const margin = 10
|
||||
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
return { left: left + (width - tooltipWidth) / 2, top: top - tooltipHeight - margin }
|
||||
case 'bottom':
|
||||
return { left: left + (width - tooltipWidth) / 2, top: top + height }
|
||||
case 'left':
|
||||
return { left: left - tooltipWidth, top: top + (height - tooltipHeight) / 2 }
|
||||
case 'right':
|
||||
return { left: left + width, top: top + (height - tooltipHeight) / 2 }
|
||||
default:
|
||||
return { left: left, top: top }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/** Fetch `/api/${route}`. Optional json `body` */
|
||||
export const api = (route: string, body?: Record<string, unknown>) =>
|
||||
fetch(`/api/${route}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
@@ -12,18 +10,12 @@ export const api = (route: string, body?: Record<string, unknown>) =>
|
||||
export const vercel60secondTimeout = (route: string, body?: Record<string, unknown>) =>
|
||||
fetch(`https://siv-git-better-unlock-error-sivteam.vercel.app/api/election/foobar/admin/unlock`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
/** https://github.com/dsernst/siv-blog/blob/main/pages/api/timeout.ts */
|
||||
export const vercel10secondTimeout = (route: string, body?: Record<string, unknown>) =>
|
||||
fetch(`https://blog.siv.org/api/timeout`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
|
||||
import { TextField, TextFieldProps } from '@mui/material'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { OnClickButton } from 'src/_shared/Button'
|
||||
import { Row } from 'src/_shared/Forms/Row'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
import { api } from 'src/api-helper'
|
||||
|
||||
export const ContactRepForm = () => {
|
||||
@@ -75,14 +77,3 @@ export const ContactRepForm = () => {
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const Row = (props: BoxProps) => (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
display: 'flex',
|
||||
margin: '1.5rem 0',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
|
||||
import { TextField, TextFieldProps } from '@mui/material'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { OnClickButton } from 'src/_shared/Button'
|
||||
import { Row } from 'src/_shared/Forms/Row'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
import { api } from 'src/api-helper'
|
||||
|
||||
const suggestedAmounts = { 10: '10', 1_000: '1k', 100_000: '100k', 1_000_000: '1m' }
|
||||
@@ -115,14 +117,3 @@ export const InvestmentForm = () => {
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const Row = (props: BoxProps) => (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
display: 'flex',
|
||||
margin: '1.5rem 0',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
|
||||
import { TextField, TextFieldProps } from '@mui/material'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { OnClickButton } from 'src/_shared/Button'
|
||||
import { Row } from 'src/_shared/Forms/Row'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
import { api } from 'src/api-helper'
|
||||
|
||||
export const QuestionForm = () => {
|
||||
@@ -72,14 +74,3 @@ export const QuestionForm = () => {
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const Row = (props: BoxProps) => (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
display: 'flex',
|
||||
margin: '1.5rem 0',
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
59
src/convention/ConventionQRPage.tsx
Normal file
59
src/convention/ConventionQRPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Spinner } from '../admin/Spinner'
|
||||
import { useConventionRedirect } from './useConventionRedirect'
|
||||
|
||||
export const ConventionQRPage = () => {
|
||||
const { convention_id, conventionRedirectInfo, errorMessage, loading, qr_id } = useConventionRedirect()
|
||||
|
||||
const { active_ballot_auth, active_redirect, convention_title } = conventionRedirectInfo || {}
|
||||
|
||||
if (!convention_id) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between max-w-lg min-h-screen p-4 pt-6 mx-auto font-sans">
|
||||
{/* Headerbar */}
|
||||
<header className="font-semibold text-center">Secure Internet Voting</header>
|
||||
|
||||
{/* Middle section */}
|
||||
{convention_id && (
|
||||
<main>
|
||||
{!errorMessage ? (
|
||||
// Loading or happy path
|
||||
<div className="-mt-24 text-2xl text-center opacity-90">
|
||||
{!loading && !active_redirect ? (
|
||||
<>
|
||||
<div className="text-lg">{convention_title}</div>
|
||||
No active election yet.
|
||||
</>
|
||||
) : (
|
||||
<div className="-ml-10 ">
|
||||
<Spinner />
|
||||
<span className="ml-2 ">Loading your ballot...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Error
|
||||
<>
|
||||
<div className="text-2xl -mt-28 opacity-90">Error: {errorMessage}</div>
|
||||
|
||||
{/* Debug info */}
|
||||
<div className="mt-10 opacity-70">
|
||||
<div className="text-sm opacity-70">Debug Info</div>
|
||||
<div>Convention ID: {convention_id}</div>
|
||||
<div>QR ID: {qr_id}</div>
|
||||
|
||||
<div className="mt-3 text-sm opacity-70">Loaded:</div>
|
||||
<div>Convention Title: {convention_title}</div>
|
||||
<div>Active ballot: {active_redirect}</div>
|
||||
<div>Ballot auth: {active_ballot_auth}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/convention/useConventionRedirect.ts
Normal file
37
src/convention/useConventionRedirect.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ConventionRedirectInfo } from 'api/conventions/[convention_id]/load-qr-redirect'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from 'src/api-helper'
|
||||
|
||||
/** Looks up redirect info for `convention_id` and `qr_id`, redirects if found. */
|
||||
export const useConventionRedirect = () => {
|
||||
const { push, query } = useRouter()
|
||||
const { convention_id, qr_id } = query
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [conventionRedirectInfo, setConventionRedirectInfo] = useState<ConventionRedirectInfo>()
|
||||
|
||||
useEffect(() => {
|
||||
setErrorMessage('')
|
||||
if (typeof convention_id !== 'string') return
|
||||
if (typeof qr_id !== 'string') return setErrorMessage('Missing qr_id')
|
||||
|
||||
setLoading(true)
|
||||
api(`conventions/${convention_id}/load-qr-redirect?qr_id=${qr_id}`).then(async (res) => {
|
||||
const json = await res.json()
|
||||
setLoading(false)
|
||||
if (!res.ok) {
|
||||
if (json.info) setConventionRedirectInfo(json.info)
|
||||
return setErrorMessage(json.error || 'Unknown error')
|
||||
}
|
||||
if (json) setConventionRedirectInfo(json.info)
|
||||
const { active_ballot_auth, active_redirect } = json.info as ConventionRedirectInfo
|
||||
|
||||
if (!active_redirect) return
|
||||
if (!active_ballot_auth) return setErrorMessage('Missing active ballot auth')
|
||||
push(`/election/${active_redirect}/vote?auth=${active_ballot_auth}`)
|
||||
})
|
||||
}, [convention_id, qr_id])
|
||||
|
||||
return { conventionRedirectInfo, convention_id, errorMessage, loading, qr_id }
|
||||
}
|
||||
@@ -51,5 +51,5 @@ function appendBuffer(buffer1: ArrayBuffer, buffer2: ArrayBuffer) {
|
||||
const temporary = new Uint8Array(buffer1.byteLength + buffer2.byteLength)
|
||||
temporary.set(new Uint8Array(buffer1), 0)
|
||||
temporary.set(new Uint8Array(buffer2), buffer1.byteLength)
|
||||
return temporary.buffer
|
||||
return temporary.buffer as ArrayBuffer
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NoSsr, TextField } from '@material-ui/core'
|
||||
import { TextField } from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
import { OnClickButton } from 'src/_shared/Button'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
|
||||
import { api } from '../api-helper'
|
||||
|
||||
|
||||
@@ -1,45 +1,11 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export const HeaderBar = (): JSX.Element => (
|
||||
<div className="bg-gradient-to-r p-4 from-[#010b26] to-[#072054] text-white">
|
||||
<div className="max-w-[750px] w-full mx-auto">
|
||||
<div className="p-4 bg-gradient-to-r from-[#010b26] to-[#072054]">
|
||||
<div className="max-w-[750px] mx-auto">
|
||||
<Link href="/">
|
||||
<a className="big">Secure Internet Voting</a>
|
||||
<a className="text-[24px] font-bold hover:no-underline text-white">Secure Internet Voting</a>
|
||||
</Link>
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.big {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
a:not(:first-child) {
|
||||
margin-left: 3rem;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a.big:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
a:not(:first-child) {
|
||||
margin-left: 0;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
div {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NoSsr } from '@material-ui/core'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
|
||||
const GA_ID = 'UA-84279342-7'
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
|
||||
import { TextField, TextFieldProps } from '@mui/material'
|
||||
import { omit } from 'lodash-es'
|
||||
import { useState } from 'react'
|
||||
import { Element } from 'react-scroll'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
|
||||
import { OnClickButton } from '../_shared/Button'
|
||||
import { api } from '../api-helper'
|
||||
@@ -136,7 +137,7 @@ export function AreYouAVoter(): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
const Row = (props: BoxProps) => (
|
||||
export const Row = (props: { children?: React.ReactNode; style?: React.CSSProperties }) => (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NoSsr, TextField } from '@material-ui/core'
|
||||
import { TextField } from '@mui/material'
|
||||
import { useState } from 'react'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
|
||||
import { api } from '../api-helper'
|
||||
import { OnClickButton } from '../_shared/Button'
|
||||
import { api } from '../api-helper'
|
||||
|
||||
export const EmailSignup = (): JSX.Element => {
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
20
src/intro/IntroPage.tsx
Normal file
20
src/intro/IntroPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { TailwindPreflight } from 'src/TailwindPreflight'
|
||||
|
||||
export const IntroPage = () => {
|
||||
return (
|
||||
<main className="max-w-sm p-3 mx-auto text-center">
|
||||
<h1 className="text-4xl font-bold text-blue-950">SIV</h1>
|
||||
<h2 className="text-2xl">Secure Internet Voting</h2>
|
||||
<ol className="flex mt-10 space-x-10 list-decimal">
|
||||
<li>One Person, One Vote</li>
|
||||
<li>Vote in Seconds</li>
|
||||
<li>Cryptographic Privacy</li>
|
||||
</ol>
|
||||
<div className="mt-7">
|
||||
<div>✅</div>
|
||||
<div>Voter Verifiable Results</div>
|
||||
</div>
|
||||
<TailwindPreflight />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NoSsr } from '@material-ui/core'
|
||||
import Head from 'next/head'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
import { GlobalCSS } from 'src/GlobalCSS'
|
||||
|
||||
import { EmailsSent } from './EmailsSent'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FormControlLabel, NoSsr, Radio, RadioGroup } from '@material-ui/core'
|
||||
import { FormControlLabel, Radio, RadioGroup } from '@mui/material'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { candidates } from './election-parameters'
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
/** Paper w/ drop shadow */
|
||||
export const Paper = ({
|
||||
children,
|
||||
className,
|
||||
marginBottom,
|
||||
noFade,
|
||||
style,
|
||||
}: {
|
||||
children: JSX.Element[] | JSX.Element
|
||||
className?: string
|
||||
marginBottom?: boolean
|
||||
noFade?: boolean
|
||||
style?: React.CSSProperties
|
||||
}) => (
|
||||
<>
|
||||
<div style={style}>{children}</div>
|
||||
<div {...{ className, style }} className={`rounded break-words py-2 px-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
div {
|
||||
border-radius: 4px;
|
||||
|
||||
box-shadow: 0px 3px 3px -2px rgba(0, 0, 0, 0.2), 0px 3px 4px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 8px 0px rgba(0, 0, 0, 0.12);
|
||||
|
||||
margin-bottom: ${marginBottom ? '30px' : 0};
|
||||
|
||||
opacity: ${noFade ? 1 : 0.7};
|
||||
overflow-wrap: break-word;
|
||||
padding: 0.5rem 1.5rem;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NoSsr, TextField } from '@material-ui/core'
|
||||
import { TextField } from '@mui/material'
|
||||
import { NoSsr } from 'src/_shared/NoSsr'
|
||||
|
||||
import { Paper } from './Paper'
|
||||
import { useVoteContext } from './VoteContext'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@material-ui/core'
|
||||
import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'
|
||||
import { range } from 'lodash-es'
|
||||
|
||||
import { voters } from './election-parameters'
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { flatten } from 'lodash-es'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { CipherStrings } from 'src/crypto/stringify-shuffle'
|
||||
import { EncryptedVote } from 'src/protocol/EncryptedVote'
|
||||
import { defaultRankingsAllowed } from 'src/vote/Ballot'
|
||||
import { generateColumnNames } from 'src/vote/generateColumnNames'
|
||||
|
||||
import { Item } from '../vote/storeElectionInfo'
|
||||
import { TotalVotesCast } from './TotalVotesCast'
|
||||
@@ -45,15 +44,7 @@ export const AcceptedVotes = ({
|
||||
|
||||
const newVotes = numVotes - votes.length
|
||||
|
||||
const columns = flatten(
|
||||
ballot_design?.map(({ id, multiple_votes_allowed, type }) =>
|
||||
multiple_votes_allowed || type === 'ranked-choice-irv'
|
||||
? new Array(multiple_votes_allowed || defaultRankingsAllowed)
|
||||
.fill('')
|
||||
.map((_, index) => `${id || 'vote'}_${index + 1}`)
|
||||
: id || 'vote',
|
||||
),
|
||||
)
|
||||
const { columns } = generateColumnNames({ ballot_design })
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -112,8 +103,8 @@ export const AcceptedVotes = ({
|
||||
if (key !== 'auth') {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<td className="monospaced">{vote[key]?.encrypted}</td>
|
||||
<td className="monospaced">{vote[key]?.lock}</td>
|
||||
<td className="monospaced text-[11px]">{vote[key]?.encrypted}</td>
|
||||
<td className="monospaced text-[11px]">{vote[key]?.lock}</td>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { orderBy } from 'lodash-es'
|
||||
import { flatten } from 'lodash-es'
|
||||
import { defaultRankingsAllowed } from 'src/vote/Ballot'
|
||||
import { generateColumnNames } from 'src/vote/generateColumnNames'
|
||||
|
||||
import { unTruncateSelection } from './un-truncate-selection'
|
||||
import { useDecryptedVotes } from './use-decrypted-votes'
|
||||
@@ -14,15 +13,7 @@ export const DecryptedVotes = ({ proofsPage }: { proofsPage?: boolean }): JSX.El
|
||||
|
||||
const sorted_votes = orderBy(votes, 'tracking')
|
||||
|
||||
const columns = flatten(
|
||||
ballot_design?.map(({ id, multiple_votes_allowed, type }) =>
|
||||
multiple_votes_allowed || type === 'ranked-choice-irv'
|
||||
? new Array(multiple_votes_allowed || defaultRankingsAllowed)
|
||||
.fill('')
|
||||
.map((_, index) => `${id || 'vote'}_${index + 1}`)
|
||||
: id || 'vote',
|
||||
),
|
||||
)
|
||||
const { columns } = generateColumnNames({ ballot_design })
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -47,7 +38,12 @@ export const DecryptedVotes = ({ proofsPage }: { proofsPage?: boolean }): JSX.El
|
||||
<td>{index + 1}.</td>
|
||||
<td>{vote.tracking?.padStart(14, '0')}</td>
|
||||
{columns.map((c) => (
|
||||
<td key={c}>{unTruncateSelection(vote[c], ballot_design, c)}</td>
|
||||
<td className="text-center" key={c}>
|
||||
{unTruncateSelection(vote[c], ballot_design, c)}
|
||||
|
||||
{/* Fix centering for negative Scores */}
|
||||
{vote[c]?.match(/^-\d$/) && <span className="inline-block w-1.5" />}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
81
src/status/IRVTallies.tsx
Normal file
81
src/status/IRVTallies.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Item } from 'src/vote/storeElectionInfo'
|
||||
|
||||
import { RoundResults } from './RoundResults'
|
||||
import { tally_IRV_Items } from './tallying/rcv-irv'
|
||||
import { unTruncateSelection } from './un-truncate-selection'
|
||||
|
||||
export const IRVTallies = ({
|
||||
ballot_design,
|
||||
id,
|
||||
results,
|
||||
}: {
|
||||
ballot_design: Item[]
|
||||
id: string
|
||||
results: ReturnType<typeof tally_IRV_Items>[string]
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
{results.winner && (
|
||||
<div>
|
||||
Winner: <b className="font-semibold">{unTruncateSelection(results.winner, ballot_design, id)}</b>{' '}
|
||||
<span className="text-xs opacity-50">
|
||||
(after {results.rounds.length} round
|
||||
{results.rounds.length === 1 ? '' : 's'})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Horizontal list of rounds */}
|
||||
<div className="flex pb-3 -ml-5 overflow-x-scroll">
|
||||
{results.rounds.map((round, index) => (
|
||||
// Vertical round results
|
||||
<div key={index}>
|
||||
<div className="pl-5 mt-3 text-xs opacity-50">Round {index + 1}</div>
|
||||
<RoundResults
|
||||
{...{ ballot_design, id, ordered: round.ordered, tallies: round.tallies, totalVotes: round.totalVotes }}
|
||||
/>
|
||||
|
||||
<CheckForTies {...{ index, ordered: round.ordered, tallies: round.tallies }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Component to check and warn about ambiguity if there's a tie among who to eliminate */
|
||||
const CheckForTies = ({
|
||||
index,
|
||||
ordered,
|
||||
tallies,
|
||||
}: {
|
||||
index: number
|
||||
ordered: string[]
|
||||
tallies: Record<string, number>
|
||||
}) => {
|
||||
const last_place = ordered.at(-1)
|
||||
if (!last_place) return <></>
|
||||
const last_votes = tallies[last_place]
|
||||
|
||||
const second_last_place = ordered.at(-2)
|
||||
if (!second_last_place) return <></>
|
||||
const second_last_votes = tallies[second_last_place]
|
||||
|
||||
if (last_votes !== second_last_votes) return <></>
|
||||
|
||||
return (
|
||||
<div
|
||||
className="px-1 ml-5 border border-yellow-400 border-solid rounded cursor-pointer text-black/70 hover:bg-yellow-50"
|
||||
onClick={() =>
|
||||
alert(
|
||||
`Round ${
|
||||
index + 1
|
||||
} has multiple tying bottom options.\n\nCurrently, the alphabetically-last gets eliminated, but your election may require different rules.`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Tie among bottom choices.{' '}
|
||||
<span className="px-[4.25px] border border-solid rounded-full opacity-50 text-xs">?</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
src/status/RoundResults.tsx
Normal file
28
src/status/RoundResults.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Item } from 'src/vote/storeElectionInfo'
|
||||
|
||||
import { unTruncateSelection } from './un-truncate-selection'
|
||||
|
||||
export const RoundResults = ({
|
||||
ballot_design,
|
||||
id,
|
||||
ordered,
|
||||
tallies,
|
||||
totalVotes,
|
||||
}: {
|
||||
ballot_design: Item[]
|
||||
id: string
|
||||
ordered: string[]
|
||||
tallies: Record<string, number>
|
||||
totalVotes: number
|
||||
}) => {
|
||||
return (
|
||||
<ul>
|
||||
{ordered?.map((selection) => (
|
||||
<li key={selection}>
|
||||
{unTruncateSelection(selection, ballot_design, id)}: {tallies[selection]}{' '}
|
||||
<i className="opacity-50 text-[12px] ml-1">({((100 * tallies[selection]) / totalVotes).toFixed(1)}%)</i>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
77
src/status/ScoreTallies.tsx
Normal file
77
src/status/ScoreTallies.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { mapValues, mean, sortBy, sum } from 'lodash-es'
|
||||
import { useState } from 'react'
|
||||
import { Item } from 'src/vote/storeElectionInfo'
|
||||
|
||||
import { useDecryptedVotes } from './use-decrypted-votes'
|
||||
|
||||
export const ScoreTallies = ({ id, options }: { id: string; options: Item['options'] }) => {
|
||||
const [showSpread, setShowSpread] = useState(false)
|
||||
|
||||
const votes = useDecryptedVotes()
|
||||
|
||||
const scoresPerOption: Record<string, number[]> = {}
|
||||
votes.forEach((vote) => {
|
||||
Object.entries(vote).forEach(([column, score]) => {
|
||||
if (column.startsWith(id)) {
|
||||
const option = column.slice(id.length + 1)
|
||||
if (!scoresPerOption[option]) scoresPerOption[option] = []
|
||||
scoresPerOption[option].push(+score)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const averagesPerOption = mapValues(scoresPerOption, mean)
|
||||
const sorted = sortBy(options, ({ name, value }) => averagesPerOption[value || name] ?? -Infinity).reverse()
|
||||
|
||||
const deviations = mapValues(scoresPerOption, stdDev)
|
||||
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr className="opacity-50 text-[12px]">
|
||||
<th></th>
|
||||
<th className="px-4" onClick={() => setShowSpread(!showSpread)}>
|
||||
Average
|
||||
</th>
|
||||
<th># cast</th>
|
||||
{showSpread && <th className="pl-4">Deviation</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(({ name, value }) => {
|
||||
const option = value || name
|
||||
|
||||
return (
|
||||
<tr key={option}>
|
||||
<td>{name}</td>
|
||||
<td className="text-center">{formatNumber(averagesPerOption[option]) ?? ''}</td>
|
||||
<td className="opacity-50 text-[12px] text-center">{(scoresPerOption[option] || []).length}</td>
|
||||
{showSpread && (
|
||||
<td className="opacity-50 text-[12px] text-center pl-4">{formatNumber(deviations[option])}</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
/** Show at most `maxPrecision` decimal places */
|
||||
const formatNumber = (num?: number, maxPrecision = 2) => {
|
||||
if (num === undefined) return
|
||||
if (num % 1 === 0) return num
|
||||
const currentPrecision = num.toString().split('.')[1].length
|
||||
const precision = Math.min(currentPrecision, maxPrecision)
|
||||
return num.toFixed(precision)
|
||||
}
|
||||
|
||||
/** Calc standard deviation of a set of numbers */
|
||||
const stdDev = (values: number[]) => {
|
||||
const avg = mean(values)
|
||||
const diffs = values.map((x) => Math.pow(x - avg, 2))
|
||||
|
||||
return Math.sqrt(sum(diffs) / values.length)
|
||||
}
|
||||
// console.log(stdDev([-4, +5, -4, +4])) // 4.26
|
||||
// console.log(stdDev([+2, -1, 0, 0])) // 1.09
|
||||
@@ -1,8 +1,10 @@
|
||||
import { keyBy } from 'lodash-es'
|
||||
import TimeAgo from 'timeago-react'
|
||||
|
||||
import { IRVTallies } from './IRVTallies'
|
||||
import { RoundResults } from './RoundResults'
|
||||
import { ScoreTallies } from './ScoreTallies'
|
||||
import { tallyVotes } from './tally-votes'
|
||||
import { unTruncateSelection } from './un-truncate-selection'
|
||||
import { useDecryptedVotes } from './use-decrypted-votes'
|
||||
import { useElectionInfo } from './use-election-info'
|
||||
|
||||
@@ -14,74 +16,45 @@ export const Totals = ({ proofsPage }: { proofsPage?: boolean }): JSX.Element =>
|
||||
if (!ballot_design || !votes || !votes.length) return <></>
|
||||
|
||||
const ballot_items_by_id = keyBy(ballot_design, 'id')
|
||||
const { ordered, tallies, totalsCastPerItems } = tallyVotes(ballot_items_by_id, votes)
|
||||
const { irv, ordered, tallies, totalsCastPerItems } = tallyVotes(ballot_items_by_id, votes)
|
||||
|
||||
return (
|
||||
<div className="totals" style={{ display: proofsPage ? 'inline-block' : undefined }}>
|
||||
<div className="title-line">
|
||||
<h3>Vote Totals:</h3>
|
||||
<div
|
||||
className={`p-4 bg-white rounded-lg ${custom_box_shadow}`}
|
||||
style={{ display: proofsPage ? 'inline-block' : undefined }}
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="mt-0">Vote Totals:</h3>
|
||||
{last_decrypted_at && !proofsPage && (
|
||||
<span>
|
||||
<span className="text-[11px] opacity-50 text-right italic">
|
||||
Last updated: <TimeAgo datetime={last_decrypted_at} opts={{ minInterval: 60 }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ballot_design.map(({ id = 'vote', title, type }) => (
|
||||
{ballot_design.map(({ id = 'vote', options, title, type }) => (
|
||||
<div key={id}>
|
||||
<h4>{title}</h4>
|
||||
|
||||
{type === 'ranked-choice-irv' && (
|
||||
<div className="p-1 border-2 border-red-400 border-dashed rounded">
|
||||
This was a Ranked Choice question. SIV does not yet support Ranked Choice tallying. The numbers below
|
||||
represent who received <i>any</i> votes, ignoring rankings.
|
||||
<p className="mt-1 mb-0 text-xs text-gray-500">
|
||||
You can paste the Decrypted Votes table into a tallying program such as{' '}
|
||||
<a href="https://rcvis.com" rel="noreferrer" target="_blank">
|
||||
rcvis.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{type === 'ranked-choice-irv' ? (
|
||||
<IRVTallies {...{ ballot_design, id, results: irv[id] }} />
|
||||
) : type === 'score' ? (
|
||||
<ScoreTallies {...{ id, options, votes }} />
|
||||
) : (
|
||||
<RoundResults
|
||||
{...{
|
||||
ballot_design,
|
||||
id,
|
||||
ordered: ordered[id],
|
||||
tallies: tallies[id],
|
||||
totalVotes: type === 'approval' ? votes.length : totalsCastPerItems[id],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{ordered[id].map((selection) => (
|
||||
<li key={selection}>
|
||||
{unTruncateSelection(selection, ballot_design, id)}: {tallies[id][selection]}{' '}
|
||||
<i style={{ fontSize: 12, marginLeft: 5, opacity: 0.5 }}>
|
||||
({((100 * tallies[id][selection]) / totalsCastPerItems[id]).toFixed(1)}%)
|
||||
</i>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<style jsx>{`
|
||||
.totals {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
|
||||
box-shadow: 0px 1px 2px hsl(0 0% 50% / 0.333), 0px 3px 4px hsl(0 0% 50% / 0.333),
|
||||
0px 4px 6px hsl(0 0% 50% / 0.333);
|
||||
}
|
||||
|
||||
.title-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 11px;
|
||||
opacity: 0.5;
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const custom_box_shadow =
|
||||
'shadow-[0px_1px_2px_hsl(0_0%_50%/_0.333),0px_3px_4px_hsl(0_0%_50%/_0.333),0px_4px_6px_hsl(0_0%_50%/_0.333)]'
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { orderBy } from 'lodash-es'
|
||||
import { Item } from 'src/vote/storeElectionInfo'
|
||||
|
||||
import { mapValues } from '../utils'
|
||||
import { tally_IRV_Items } from './tallying/rcv-irv'
|
||||
|
||||
export function tallyVotes(ballot_items_by_id: Record<string, unknown>, votes: Record<string, string>[]) {
|
||||
const multi_vote_regex = /_\d+$/
|
||||
export function tallyVotes(ballot_items_by_id: Record<string, Item>, votes: Record<string, string>[]) {
|
||||
const multi_vote_regex = /_(\d+)$/
|
||||
|
||||
// Sum up votes
|
||||
const tallies: Record<string, Record<string, number>> = {}
|
||||
const IRV_columns_seen: Record<string, boolean> = {}
|
||||
votes.forEach((vote) => {
|
||||
Object.keys(vote).forEach((key) => {
|
||||
// Skip 'tracking' key
|
||||
@@ -19,7 +22,11 @@ export function tallyVotes(ballot_items_by_id: Record<string, unknown>, votes: R
|
||||
// We'll also check that it's not on the ballot schema, just to be safe
|
||||
if (multi_suffix && !ballot_items_by_id[key]) {
|
||||
// If so, we need to add all tallies to seed id, not the derived keys
|
||||
item = key.slice(0, -(multi_suffix.length + 1))
|
||||
// Use the matched suffix digit to slice off the right length
|
||||
item = key.slice(0, -multi_suffix[0].length)
|
||||
|
||||
// RCV-IRV items use a different tallying algorithm, so we skip them for now
|
||||
if (ballot_items_by_id[item]?.type === 'ranked-choice-irv') return (IRV_columns_seen[key] = true)
|
||||
}
|
||||
|
||||
// Init item if new
|
||||
@@ -32,6 +39,8 @@ export function tallyVotes(ballot_items_by_id: Record<string, unknown>, votes: R
|
||||
tallies[item][vote[key]]++
|
||||
})
|
||||
})
|
||||
|
||||
// Calc total votes cast per item, to speed up calc'ing %s
|
||||
const totalsCastPerItems: Record<string, number> = {}
|
||||
Object.keys(tallies).forEach((item) => {
|
||||
totalsCastPerItems[item] = 0
|
||||
@@ -47,7 +56,8 @@ export function tallyVotes(ballot_items_by_id: Record<string, unknown>, votes: R
|
||||
),
|
||||
) as Record<string, string[]>
|
||||
|
||||
// const ordered = {}
|
||||
// Go back and IRV tally any of those that we skipped
|
||||
const irv = tally_IRV_Items(IRV_columns_seen, ballot_items_by_id, votes)
|
||||
|
||||
return { ordered, tallies, totalsCastPerItems }
|
||||
return { irv, ordered, tallies, totalsCastPerItems }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user