koro

an event time scheduler
git clone https://tilde.team/~marisa/repo/koro.git
Log | Files | Refs | README | LICENSE

commit 30d9b43b8a4c59985bee88291d6261654de1fabe
parent c1148fdd6a17b206841a41e80ece4b415f63cb27
Author: mokou <mokou@posteo.de>
Date:   Thu, 30 Jul 2020 04:59:22 +0200

feat: Add poll creation

Diffstat:
Mpackage-lock.json | 20+++++++++++++++++++-
Mpackage.json | 2++
Msrc/App.svelte | 4++++
Msrc/couch.js | 11++++++-----
Msrc/main.css | 15+++++++++++++++
Asrc/pages/CreatePoll.svelte | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/pages/Event.svelte | 4++++
Msrc/pages/Index.svelte | 4++++
Asrc/pages/Poll.svelte | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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}