commit 30d9b43b8a4c59985bee88291d6261654de1fabe
parent c1148fdd6a17b206841a41e80ece4b415f63cb27
Author: mokou <mokou@posteo.de>
Date: Thu, 30 Jul 2020 04:59:22 +0200
feat: Add poll creation
Diffstat:
9 files changed, 442 insertions(+), 6 deletions(-)
diff --git a/package-lock.json b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "koro",
- "version": "1.2.0",
+ "version": "1.3.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1189,6 +1189,14 @@
"simple-swizzle": "^0.2.2"
}
},
+ "colormap": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/colormap/-/colormap-2.3.1.tgz",
+ "integrity": "sha512-TEzNlo/qYp6pBoR2SK9JiV+DG1cmUcVO/+DEJqVPSHIKNlWh5L5L4FYog7b/h0bAnhKhpOAvx/c1dFp2QE9sFw==",
+ "requires": {
+ "lerp": "^1.0.3"
+ }
+ },
"commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -2985,6 +2993,11 @@
}
}
},
+ "font-color-contrast": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/font-color-contrast/-/font-color-contrast-1.0.3.tgz",
+ "integrity": "sha1-d9iP+Xxr0JXPqd/rRGJLqK1xe2w="
+ },
"for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
@@ -4030,6 +4043,11 @@
"invert-kv": "^2.0.0"
}
},
+ "lerp": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/lerp/-/lerp-1.0.3.tgz",
+ "integrity": "sha1-oYyJaPkXiW3hXM/MKNVaa3Med24="
+ },
"level": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/level/-/level-6.0.0.tgz",
diff --git a/package.json b/package.json
@@ -9,10 +9,12 @@
},
"dependencies": {
"accessible-autocomplete": "^2.0.2",
+ "colormap": "^2.3.1",
"css-loader": "^3.5.3",
"cssnano": "^4.1.10",
"dayjs": "^1.8.27",
"envalid": "^6.0.1",
+ "font-color-contrast": "^1.0.3",
"fuzzysearch": "^1.0.3",
"is-online": "^8.3.1",
"mini-css-extract-plugin": "^0.9.0",
diff --git a/src/App.svelte b/src/App.svelte
@@ -3,6 +3,8 @@
import { is24Hrs, isDarkMode } from './stores'
import Index from './pages/Index.svelte'
import Event from './pages/Event.svelte'
+ import CreatePoll from './pages/CreatePoll.svelte'
+ import Poll from './pages/Poll.svelte'
import Log from './pages/Log.svelte'
export let router = navaid()
@@ -10,7 +12,9 @@
let routeParams
router.on('/', setRoute(Index))
+ router.on('/poll', setRoute(CreatePoll))
router.on('/updates', setRoute(Log))
+ router.on('/p/:id', setRoute(Poll))
router.on('/:id', setRoute(Event))
router.listen()
diff --git a/src/couch.js b/src/couch.js
@@ -2,10 +2,10 @@ import PouchDB from 'pouchdb-browser'
import { isPouchAccessible } from './stores'
import { get } from 'svelte/store'
-export async function pouch () {
+export async function pouch() {
// We obtain the database, and then check if we can access it.
const local = new PouchDB('koro', {
- auto_compation: true
+ auto_compaction: true
})
try {
@@ -25,16 +25,17 @@ export async function pouch () {
export async function replicateToCouch() {
if (get(isPouchAccessible)) {
PouchDB.replicate('koro', process.env.KORO_COUCHDB_URL, { live: false })
- .on('complete', (info) => info)
- .on('error', (err) => new Error(err))
+ .on('complete', (info) => info)
+ .on('error', (err) => new Error(err))
}
}
-export async function syncDoc(id, onChange, onError) {
+export async function syncDoc(id) {
if (get(isPouchAccessible)) {
const db = await pouch()
return db.sync(process.env.KORO_COUCHDB_URL, {
live: true,
+ retry: true,
selector: { _id: { $eq: id } }
})
}
diff --git a/src/main.css b/src/main.css
@@ -72,6 +72,21 @@ input.dark,
@apply pl-3;
}
+.hover-box {
+ transition: .5s transform;
+ border: 1px solid transparent;
+}
+
+.hover-box:hover {
+ transform: scale(0.99);
+ cursor: pointer;
+}
+
+.hover-box-selected {
+ transform: scale(0.99);
+ border: 1px solid white;
+}
+
.autocomplete__menu {
@apply w-full bg-white border border-gray-400 rounded border-t-0;
}
diff --git a/src/pages/CreatePoll.svelte b/src/pages/CreatePoll.svelte
@@ -0,0 +1,172 @@
+<script>
+ import { nanoid } from 'nanoid'
+ import isOnline from 'is-online'
+ import { onMount } from 'svelte'
+ import { pouch, replicateToCouch } from '../couch'
+ import { isDarkMode } from '../stores'
+
+ let poll = {
+ _id: `p:${nanoid()}`,
+ question: '',
+ answers: [],
+ isMulti: false,
+ showResultsBeforeVoting: true
+ }
+ let answerInput = ''
+ let errorFlash = ''
+ let submitting = false
+ let success = false
+ let pollLink = ''
+
+ function answerKeyPress(evt) {
+ if ((evt.keyCode === 10 || evt.keyCode === 13) && evt.ctrlKey) {
+ addAnswer()
+ }
+ }
+
+ function addAnswer() {
+ poll.answers.push(answerInput)
+ // So that Svelte rerenders
+ poll = poll
+ answerInput = ''
+ }
+
+ function removeAnswer(i) {
+ return () => {
+ poll.answers.splice(i, 1)
+ poll = poll
+ }
+ }
+
+ async function submitForm(evt) {
+ if (evt.explicitOriginalTarget.id === 'answers') {
+ return
+ }
+
+ errorFlash = ''
+ submitting = true
+ if (!poll.question) {
+ errorFlash = 'Please provide a question or prompt!'
+ submitting = false
+ return
+ }
+
+ if (poll.answers.length === 0) {
+ errorFlash = 'Please provide at least one answer!'
+ submitting = false
+ return
+ }
+
+ const db = await pouch()
+ try {
+ const res = await db.put(poll)
+ await replicateToCouch()
+ success = true
+ pollLink = `https://koro.moe/p/${res.id}`
+ } catch (e) {
+ console.error(e)
+ }
+ }
+
+ onMount(async () => {
+ if (!(await isOnline())) {
+ errorFlash = `You're offline! This means you can still create a poll, but you'll have to be online before you can share the link with others.`
+ }
+ })
+</script>
+
+<h1 class="text-5xl font-serif font-extrabold">Create Poll</h1>
+
+{#if success}
+ <p class="text-2xl font-medium max-w-xl leading-normal">
+ Your poll has been successfully created! You can share the link below with
+ anyone you want, and they'll be able to answer. Don't forget to do it
+ yourself!
+ </p>
+
+ <div
+ class="text-2xl font-mono bg-black text-white max-w-xl w-full px-3 py-2 mt-5
+ text-center">
+ {pollLink}
+ </div>
+{:else}
+ <p class="text-2xl font-medium max-w-xl leading-normal">
+ Here, you can create a very basic poll with a question and multiple answers,
+ which respondents can then pick from.
+ </p>
+
+ <form class="max-w-xl mt-6" on:submit|preventDefault={submitForm}>
+ {#if errorFlash}
+ <div
+ class="w-full bg-red-100 px-3 py-2 border rounded border-red-400
+ text-red-600 my-5">
+ {errorFlash}
+ </div>
+ {/if}
+ <label
+ for="question"
+ class="font-bold text-gray-300"
+ class:text-gray-600={!$isDarkMode}>
+ Your question or prompt
+ </label>
+ <input
+ id="question"
+ type="text"
+ bind:value={poll.question}
+ class:dark={$isDarkMode}
+ placeholder="Tea or coffee!" />
+
+ <label
+ for="answers"
+ class="font-bold text-gray-300 inline-block mt-4"
+ class:text-gray-600={!$isDarkMode}>
+ Write your answers here, one by one
+ </label>
+ <input
+ id="answers"
+ type="text"
+ bind:value={answerInput}
+ class:dark={$isDarkMode}
+ on:keydown={answerKeyPress}
+ placeholder="Press Ctrl+Enter to add answers quickly!" />
+
+ <ul class="list-disc my-3">
+ {#each poll.answers as answer, i}
+ <li>
+ <span on:click={removeAnswer(i)}>❌</span>
+ <span class="font-bold">{answer}</span>
+ </li>
+ {/each}
+ </ul>
+
+ <div>
+ <input
+ id="isMulti"
+ type="checkbox"
+ bind:checked={poll.isMulti}
+ class="inline-block w-2 mr-2 align-middle" />
+ <label for="isMulti" class="font-bold align-middle">
+ Allow multiple answers?
+ </label>
+ </div>
+
+ <div class="mt-2">
+ <input
+ id="withNames"
+ type="checkbox"
+ bind:checked={poll.showResultsBeforeVoting}
+ class="inline-block w-2 mr-2 align-middle" />
+ <label for="withNames" class="font-bold align-middle">
+ Show results before voting?
+ </label>
+ </div>
+
+ <button
+ class="btn mt-5"
+ type="submit"
+ on:click|preventDefault={submitForm}
+ disabled={submitting}>
+ Create Poll
+ </button>
+ </form>
+{/if}
diff --git a/src/pages/Event.svelte b/src/pages/Event.svelte
@@ -6,6 +6,7 @@
import relativeTime from 'dayjs/plugin/relativeTime'
import { onMount, onDestroy } from 'svelte'
import { get } from 'svelte/store'
+ import navaid from 'navaid'
import { pouch, replicateToCouch, syncDoc } from '../couch'
import ResponseGrid from '../components/ResponseGrid.svelte'
import { hasResponded, is24Hrs, isDarkMode } from '../stores'
@@ -86,6 +87,9 @@
event = await db.get(params.id)
loading = false
document.title = `${event.name} - Koro`
+ if (event.question) {
+ navaid().route(`/p/${params.id}`)
+ }
} catch (e) {
error =
"Couldn't fetch the event you're looking for. Do you have the right link? Or maybe you're offline?"
diff --git a/src/pages/Index.svelte b/src/pages/Index.svelte
@@ -116,6 +116,10 @@
supports the user's timezone and works locally, too.
</p>
+ <p class="text-2xl my-4 font-medium max-w-xl leading-normal">
+ You can also create a <a class="text-purple-400" href="/poll">regular poll</a>.
+ </p>
+
<form class="max-w-xl mt-6" on:submit|preventDefault={submitForm}>
{#if errorFlash}
<div
diff --git a/src/pages/Poll.svelte b/src/pages/Poll.svelte
@@ -0,0 +1,216 @@
+<script>
+ export let params
+
+ import colormap from 'colormap'
+ import contrast from 'font-color-contrast'
+ import { onMount, onDestroy } from 'svelte'
+ import { get } from 'svelte/store'
+ import { nanoid } from 'nanoid'
+ import { pouch, replicateToCouch, syncDoc } from '../couch'
+ import { hasResponded, isDarkMode } from '../stores'
+
+ let loading = true
+ let live = null
+ let errorFlash = ''
+ let poll = {}
+ let votes = []
+ let multiSelection = []
+ let colors = null
+
+ async function getPoll() {
+ const db = await pouch()
+ try {
+ poll = await db.get(params.id)
+ colors = colormap({
+ colormap: 'winter',
+ nshades: poll.answers.length,
+ format: 'hex',
+ alpha: 1
+ })
+ if (poll.responses) {
+ votes = poll.answers.map((_a, i) => {
+ return Object.values(poll.responses).reduce((acc, j) => {
+ if (poll.isMulti) {
+ return j.includes(Number(i)) ? acc + 1 : acc
+ }
+ return i === j ? acc + 1 : acc
+ }, 0)
+ })
+ }
+ loading = false
+ document.title = `${poll.question} - Koro`
+ } catch (e) {
+ errorFlash = `Couldn't fetch the poll you're looking for. Do you have the right link? Or maybe you're offline?`
+ throw new Error('No document exists!')
+ }
+ }
+
+ function getStyle(i) {
+ return `background-color: ${colors[i]}; color: ${contrast(colors[i])}`
+ }
+
+ function answerClick(i) {
+ return () => {
+ if (poll.isMulti && multiSelection.includes(i)) {
+ multiSelection.splice(multiSelection.indexOf(i), 1)
+ } else if (poll.isMulti) {
+ multiSelection.push(i)
+ } else {
+ submitSingle(i)
+ }
+ poll = poll
+ multiSelection = multiSelection
+ }
+ }
+
+ async function submitSingle(i) {
+ await putDoc(i)
+ }
+
+ async function submitMulti() {
+ await putDoc(multiSelection)
+ }
+
+ function hasMostVotes(i) {
+ const max = Math.max(...votes)
+ let indices = []
+ votes.forEach((v, j) => {
+ if (v === max) {
+ indices.push(j)
+ }
+ })
+ if (indices.length > 1) {
+ return indices.some((f) => f === i)
+ }
+ return indices[0] === i
+ }
+
+ function countVotes(i) {
+ return votes[i]
+ }
+
+ function totalVotes(i) {
+ return votes.reduce((acc, j) => acc + j)
+ }
+
+ async function putDoc(contents) {
+ const db = await pouch()
+ const respondent = nanoid()
+ let responsesToPut = poll.responses || new Object()
+ responsesToPut[respondent] = contents
+ try {
+ await db.put({
+ responses: responsesToPut,
+ ...poll
+ })
+ const newResponded = get(hasResponded) || new Object()
+ newResponded[params.id] = {
+ name: respondent
+ }
+ hasResponded.set(newResponded)
+ await getPoll()
+ } catch (e) {
+ console.error(e)
+ }
+ }
+
+ onMount(async () => {
+ // Make sure our changes are in the remote Couch
+ try {
+ await replicateToCouch()
+ } catch (e) {
+ console.error(e)
+ }
+
+ // Try and get poll information from the database
+ try {
+ await getPoll()
+ } catch (e) {
+ loading = true
+ errorFlash = ''
+ }
+
+ // Set up replication
+ live = await syncDoc(params.id)
+ live
+ .on('change', async (change) => {
+ const db = await pouch()
+ if (change.direction === 'pull') {
+ getPoll().catch((err) => {
+ errorFlash = `Couldn't find the poll. You might be offline!`
+ })
+ }
+ })
+ .on('error', (err) => {
+ console.error(err)
+ })
+ })
+
+ onDestroy(() => {
+ live.cancel()
+ })
+</script>
+
+{#if loading}
+ Loading poll information. You might need to refresh the page.
+{:else if loading && errorFlash}
+ {errorFlash}
+{:else}
+ <h1 class="text-5xl font-serif font-extrabold">{poll.question}</h1>
+ <div class="max-w-xl">
+ {#if errorFlash}
+ <div
+ class="w-full bg-red-100 px-3 py-2 border rounded border-red-400
+ text-red-600 my-5">
+ {errorFlash}
+ </div>
+ {/if}
+ </div>
+ {#if !poll.responses || poll.responses.length === 0}
+ <p class="text-xl mb-5 max-w-xl">
+ Someone has shared this poll and wants you to give your answer! You can be
+ the first!
+ </p>
+ {:else if (poll.showResultsBeforeVoting && poll.responses) || $hasResponded[params.id]}
+ <div class="max-w-xl">
+ {#each poll.answers as answer, i}
+ <div style={getStyle(i)} class="rounded-t text-center py-3 px-3">
+ {answer}
+ </div>
+ <div
+ class="rounded-b mb-4 py-3 px-3 text-center"
+ class:bg-green-500={hasMostVotes(i)}
+ class:bg-red-500={!hasMostVotes(i)}>
+ {countVotes(i)}/{totalVotes(i)} votes
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ {#if !$hasResponded || !$hasResponded[params.id]}
+ {#if poll.isMulti}
+ <p class="text-xl mb-4 max-w-xl">This poll supports multiple answers.</p>
+ {/if}
+ <h2 class="text-3xl font-serif font-extrabold">Respond to this poll</h2>
+ {#each poll.answers as answer, i}
+ <div
+ class="rounded py-3 px-3 mb-3 shadow-xl hover-box"
+ class:hover-box-selected={poll.isMulti && multiSelection.includes(i)}
+ class:border-white={$isDarkMode && multiSelection.includes(i)}
+ class:border-black={!$isDarkMode && multiSelection.includes(i)}
+ style={getStyle(i)}
+ on:click={answerClick(i)}>
+ {answer}
+ </div>
+ {/each}
+
+ {#if poll.isMulti}
+ <button
+ class="btn mt-5"
+ disabled={multiSelection.length === 0}
+ on:click={submitMulti}>
+ Submit
+ </button>
+ {/if}
+ {:else}You've already responded to this poll!{/if}
+{/if}