Add page to create an election

This commit is contained in:
2025-01-13 19:58:42 +01:00
parent c7cccf2ec1
commit ee847020f7
7 changed files with 239 additions and 125 deletions

View File

@ -4,63 +4,152 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New Election</title>
<link rel="stylesheet" href="/static/styles.css">
<link rel="stylesheet" href="/static/css/styles.css">
<script src="https://unpkg.com/vue@3"></script>
</head>
<body>
<div class="container">
<div id="app" class="container">
<main>
<h1>Create New Election</h1>
<form action="/elections" method="POST" class="form">
<form @submit.prevent="createElection" class="form">
<div class="form-group">
<label for="name">Election Name</label>
<input type="text" id="name" name="name" required>
<input type="text" id="name" v-model="election.name" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="tokens">Tokens per Voter</label>
<input type="number" id="tokens" name="tokens" min="1" required>
<input type="number" id="tokens" v-model.number="election.tokens" min="1" required>
</div>
<div class="form-group">
<label for="are_voters_known">Voter Access</label>
<select id="are_voters_known" name="are_voters_known" required>
<option value="1">Known voters only</option>
<option value="0">Open to anyone</option>
<label for="areVotersKnown">Voter Access</label>
<select id="areVotersKnown" v-model="election.areVotersKnown" required>
<option :value="true">Known voters only</option>
<option :value="false">Open to anyone</option>
</select>
<small>Known voters only = codes will be generated and you must give those to your voters</small>
</div>
</div>
<div class="form-group" id="max-voters-group">
<label for="max_voters">Maximum Number of Voters</label>
<input type="number" id="max_voters" name="max_voters" min="1">
<div class="form-group">
<label for="maxVoters">Maximum Number of Voters</label>
<input type="number" id="maxVoters" v-model.number="election.maxVoters" min="1">
<small>0 = unlimited</small>
<span v-if="election.areVotersKnown && election.maxVoters <= 0" class="error-text">Maximum number of voters must be greater than 0 if voters are known</span>
</div>
<div class="form-group">
<label for="expires_at">Expiration Date</label>
<input type="datetime-local" id="expires_at" name="expires_at" required>
<label for="expiresAt">Expiration Date</label>
<input type="datetime-local" id="expiresAt" v-model="election.expiresAt" required>
</div>
<div class="form-group">
<label>Choices</label>
<div id="choices-container">
<div class="choice-input">
<input type="text" name="choices[]" required>
<button type="button" class="remove-choice" hidden>×</button>
</div>
<div class="choice-input">
<input type="text" name="choices[]" required>
<button type="button" class="remove-choice" hidden>×</button>
<div v-for="(choice, index) in election.choices" :key="index" class="choice-input">
<input type="text" v-model="election.choices[index]" required>
<button type="button" class="remove-choice" @click="removeChoice(index)" v-show="election.choices.length > 2">×</button>
</div>
</div>
<button type="button" id="add-choice">Add Another Choice</button>
<button type="button" id="add-choice" @click="addChoice">Add Another Choice</button>
</div>
<div class="form-actions">
<button type="submit">Create Election</button>
</div>
</form>
<div v-if="voterIdentities.length > 0" class="voter-codes">
<h2>Voter Access Codes</h2>
<div class="codes-container">
<button @click="copyAllCodes" class="copy-all-btn">
Copy All Codes
</button>
<div class="codes-list">
<div v-for="(code, index) in voterIdentities"
:key="code"
class="code-item">
<span class="code-number">{{ index + 1 }}.</span>
<span class="code-text">{{ code }}</span>
<button @click="copyCode(code)" class="copy-btn">
Copy
</button>
</div>
</div>
</div>
</div>
</main>
</div>
<script>
const app = Vue.createApp({
data() {
return {
election: {
name: "",
tokens: 100,
areVotersKnown: true,
maxVoters: 0,
expiresAt: "",
choices: ["", ""] // Start with two empty choices
},
voterIdentities: []
};
},
methods: {
addChoice() {
this.election.choices.push(""); // Add a new empty choice
},
removeChoice(index) {
this.election.choices.splice(index, 1); // Remove choice by index
},
async copyCode(code) {
try {
await navigator.clipboard.writeText(code);
// Optional: Add visual feedback that copy succeeded
} catch (err) {
console.error('Failed to copy code:', err);
}
},
async copyAllCodes() {
try {
const allCodes = this.voterIdentities.join('\n');
await navigator.clipboard.writeText(allCodes);
// Optional: Add visual feedback that copy succeeded
} catch (err) {
console.error('Failed to copy codes:', err);
}
},
createElection() {
this.voterIdentities = [];
const payload = {
...this.election,
expiresAt: this.election.expiresAt + ":00Z", // Add timezone if necessary
choices: this.election.choices.filter(choice => choice.trim() !== "") // Filter out empty choices
};
fetch("/election", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(data => {
this.voterIdentities = data.voterIdentities;
})
.catch(error => {
alert("Failed to create election.");
});
}
}
});
app.mount("#app");
</script>
</body>
</html>

View File

@ -98,6 +98,11 @@ input:focus, select:focus {
flex: 1;
}
.error-text {
color: red;
font-size: 12px;
}
button {
background: var(--primary-color);
color: white;

1
ui/static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,120 @@
htmx.defineExtension('json-enc-custom', {
onEvent: function (name, evt) {
if (name === "htmx:configRequest") {
evt.detail.headers['Content-Type'] = "application/json";
}
},
encodeParameters: function (xhr, parameters, elt) {
xhr.overrideMimeType('text/json');
let encoded_parameters = encodingAlgorithm(parameters);
return encoded_parameters;
}
});
function encodingAlgorithm(parameters) {
let resultingObject = Object.create(null);
const PARAM_NAMES = Object.keys(parameters);
const PARAM_VALUES = Object.values(parameters);
const PARAM_LENGHT = PARAM_NAMES.length;
for (let param_index = 0; param_index < PARAM_LENGHT; param_index++) {
let name = PARAM_NAMES[param_index];
let value = PARAM_VALUES[param_index];
let steps = JSONEncodingPath(name);
let context = resultingObject;
for (let step_index = 0; step_index < steps.length; step_index++) {
let step = steps[step_index];
context = setValueFromPath(context, step, value);
}
}
let result = JSON.stringify(resultingObject);
return result
}
function JSONEncodingPath(name) {
let path = name;
let original = path;
const FAILURE = [{ "type": "object", "key": original, "last": true, "next_type": null }];
let steps = Array();
let first_key = String();
for (let i = 0; i < path.length; i++) {
if (path[i] !== "[") first_key += path[i];
else break;
}
if (first_key === "") return FAILURE;
path = path.slice(first_key.length);
steps.push({ "type": "object", "key": first_key, "last": false, "next_type": null });
while (path.length) {
// [123...]
if (/^\[\d+\]/.test(path)) {
path = path.slice(1);
let collected_digits = path.match(/\d+/)[0]
path = path.slice(collected_digits.length);
let numeric_key = parseInt(collected_digits, 10);
path = path.slice(1);
steps.push({ "type": "array", "key": numeric_key, "last": false, "next_type": null });
continue
}
// [abc...]
if (/^\[[^\]]+\]/.test(path)) {
path = path.slice(1);
let collected_characters = path.match(/[^\]]+/)[0];
path = path.slice(collected_characters.length);
let object_key = collected_characters;
path = path.slice(1);
steps.push({ "type": "object", "key": object_key, "last": false, "next_type": null });
continue;
}
return FAILURE;
}
for (let step_index = 0; step_index < steps.length; step_index++) {
if (step_index === steps.length - 1) {
let tmp_step = steps[step_index];
tmp_step["last"] = true;
steps[step_index] = tmp_step;
}
else {
let tmp_step = steps[step_index];
tmp_step["next_type"] = steps[step_index + 1]["type"];
steps[step_index] = tmp_step;
}
}
return steps;
}
function setValueFromPath(context, step, value) {
if (step.last) {
context[step.key] = value;
}
//TODO: make merge functionality and file suport.
//check if the context value already exists
if (context[step.key] === undefined) {
if (step.type === "object") {
if (step.next_type === "object") {
context[step.key] = {};
return context[step.key];
}
if (step.next_type === "array") {
context[step.key] = [];
return context[step.key];
}
}
if (step.type === "array") {
if (step.next_type === "object") {
context[step.key] = {};
return context[step.key];
}
if (step.next_type === "array") {
context[step.key] = [];
return context[step.key];
}
}
}
else {
return context[step.key];
}
}