Avatar

tmcw

👷 Building Val Town
157 public vals
Joined August 31, 2022

Get a DuckDB database

This method sets up a duckdb-wasm database that you can then use to store and retrieve data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { fetch } from "https://esm.town/v/std/fetch";
export let getDuckDB = (async () => {
async function createWorker(url: string) {
const workerScript = await fetch(url);
const workerURL = URL.createObjectURL(await workerScript.blob());
return new Worker(workerURL, { type: "module" });
}
const duckdb = await import(
"https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.17.0/+esm"
);
const bundles = duckdb.getJsDelivrBundles();
const bundle = await duckdb.selectBundle(bundles);
const logger = new duckdb.ConsoleLogger();
const worker = await createWorker(bundle.mainWorker);
const db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule);
return db;
});

Unfancy blocks

Screenshot

In the old days, there was a website called bl.ocks.org which re-hosted content on GitHub Gists and displayed visualizations made in those gists. It wasn't shiny but it was well-crafted and for the little community that used it, it was very nice.

This is a much worse, but very small, version of the same kind of program. This also shows you stuff from blocks. It displays readmes, with Titus's lovely micromark. It uses Ian's collected search index of bl.ocks.org to show you examples and thumbnails. It uses Alpine to pull together a little interface. And, of course, Val Town to make it all work.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import { fetch } from "https://esm.town/v/std/fetch";
export let blocks = async (req, res) => {
const { micromark } = await import("npm:micromark");
const [_, user, id, ...file] = req.path.split("/");
if (!user) {
return res.send(`
<html>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<body class='p-20 bg-stone-100'>
<div
x-data="{ page: 0, blocks: [],
get filteredBlocks() {
return this.blocks.filter(block => block.thumbnail)
.reverse()
.map(block => {
return {
...block,
href: '/' + block.userId + '/' + block.id
}
}).slice(this.page * 100, (this.page + 1) * 100)
}
}"
x-init="blocks = await (await fetch('https://storage.googleapis.com/bb-search-data/parsed/blocks-min.json')).json()"
>
<div class='grid gap-4 grid-cols-4 lg:grid-cols-8'>
<template x-for="block in filteredBlocks">
<a class="aspect-square p-4 block" x-bind:href="block.href">
<div>
<span x-text="block.description"></span>
by <span x-text="block.userId"></span>
</div>
<template x-if="block.thumbnail">
<img x-bind:src="block.thumbnail" class='w-full' />
</template>
</a>
</template>
</div>
<div class='flex items-center justify-center gap-x-2 pt-20'>
Page <div x-text="page"></div>
<template x-if="page > 0">
<button @click="page--">Previous page</button>
</template>
<button @click="page++">Next page</button>
</div>
</div>
</body>
</body>
</html>`);
}
const gistRes = await fetch(`https://api.github.com/gists/${id}`);
if (!gistRes.ok) {
return res.send("GitHub replied with a non-200 status.");
}
const gist = await gistRes.json();
const readme = gist.files["README.md"];
const blocks = gist.files[".block"];
let height = 700;
if (blocks) {
const { parse } = await import("npm:yaml");
try {
const v = parse(blocks.content);
if (v.height) {
height = +v.height;
}
}
catch (e) {
}
}
res.set("Cache-Control", "public,max-age=64800");
return res.send(`
<html>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<body class='bg-stone-100'>
<div class='max-w-5xl mx-auto py-10'>
<div class='prose'>
<a href='https://tmcw-blocks.express.val.run/'>unfancy blocks</a> /
<a href='https://gist.github.com/${user}/${id}'>gist: ${user}/${id}</a>
<hr />
${readme ? micromark(readme.content) : ""}
${
blocks?.content
? `<h4>.blocks</h4><pre><code>${blocks?.content.trim()}</code></pre>`
: ""
}
</div>
<iframe style='height:${height}px;' class='mt-10 w-full border border-black' src='https://tmcw-blocks_inner.express.val.run/${user}/${id}/'>
</iframe>
</div>
</body>
</html>`);
};

Val Town API: Find a val by handle and name

This val fetches itself via the Val Town API! Weird, right?

1
2
3
4
5
6
7
8
import { fetch } from "https://esm.town/v/std/fetch";
export let valTownApiExampleVal = (async () => {
const response = await fetch(
`https://api.val.town/v1/alias/tmcw/valTownApiExampleVal`,
);
return await response.json();
})();

TODO List

Every web project needs a todo list, right? This is val.town's first (maybe?) With a healthy dose of htmx and web fundamentals, we're packing the essentials of a TODO list with server persistence and animations into about 60 lines. The data is stored in the @tmcw.todos val, for now. Try it out.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
let { todos } = await import("https://esm.town/v/tmcw/todos");
export let todo_list = (async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const renderTodo = (todo) => {
return html`<li data-id=${todo.id}>
<label>
<input hx-delete="/" hx-target="[data-id='${todo.id}']" hx-swap="delete" style='display:inline;margin-right:0.5rem;' type='checkbox' name="id" value=${todo.id} />
${todo.name}
</label>
</li>`;
};
switch (req.method) {
case "POST": {
const name = req.body.name.substring(0, 256).trim();
if (!name)
return res.end();
const todo = {
id: crypto.randomUUID(),
name,
};
todos = [...todos, todo];
if (todos.length > 10) {
todos = todos.slice(-10);
res.set("HX-Trigger", JSON.stringify({ reload: true }));
}
return res.send(renderTodo(todo));
}
case "DELETE": {
todos = todos.filter((todo) =>
todo.id !== req.body.id
);
return res.end();
}
}
if (req.path === "/list") {
return res.send(html`<>${todos.map(renderTodo)}</>`);
}
return res.send(html`<html><head><title>TODO</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://the.missing.style" />
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>
<body>
<main>
<h2>Todo list</h2>
<ul style='list-style-type:none;padding-left:0;' hx-trigger="reload from:body" hx-get="/list">
${todos.map(renderTodo)}
</ul>
<form method="post" class='f-row' hx-post="/" hx-target="ul" hx-swap="beforeend settle:1s">
<input required name="name" type="text" value="" /><button type="submit">Add</button>
</form>
</main>
<footer>
<p>Powered by Val Town: <a href='https://www.val.town/v/tmcw.todo_list'>tmcw.todo_list</a></p>
</footer>
<style>
form.htmx-request { opacity: .5; transition: opacity 300ms linear; }
li { transition: background 1s ease-out; }
li.htmx-added { background: yellow; }
</style>
</body>
</html>`);
});

The Big Story

This val, along with @tmcw.big_story, which requests from the New York Times API, and @tmcw.big_stories_ranks, which contains the data, generates a visualization of top stories on the NYTimes homepage.

This is here just to ask the question – what happens to cover stories over time? Do they slowly drop down the page, or just get replaced by a fully new lede? So far it doesn't have quite enough data to answer that question.

But also, it might be neat because it'll show which kinds of stories make the front page - is it climate, war, politics, or something else?

👉 The Big Story (visualization)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
export let big_story_visualization = async (req, res) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const html = htm.bind(vhtml);
const raw = (html) =>
vhtml(null, {
dangerouslySetInnerHTML: { __html: html },
});
res.send(html`
<html>
<body style="font-family: sans-serif;padding:30px;">
<h1>Big stories on the New York Times</h1>
<p>Below is a chart of headlines from the New York Times homepage, ordered
by their rank over time. Most stories start near the top and then get lower-ranked
when other news breaks. Some stories pop back up the ranks.
</p>
<p>Check out <a href='https://www.val.town/v/tmcw.big_story_visualization'>the code that makes this work</a> on <a href='https://val.town/'>val.town</a>.
</p>
<p>Hover over a line to see the story title, click on a line to view the story.</p>
<div style='padding-top:40px' id='chart'></div>
<script type="module">
${
raw(`
import * as Plot from "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.9/+esm";
import {debounce} from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/+esm";
const ranks = await (await fetch('https://api.val.town/v1/run/tmcw.big_stories_ranks')).json();
let allDates = new Set();
for (let rank of Object.values(ranks)) {
for (let [date] of rank.ranks) {
allDates.add(date);
}
}
allDates = [...allDates.values()].sort((a, b) => a - b);
const normalized = Object.entries(ranks).flatMap(([url, data]) => {
return allDates.map(date => {
return {
...data,
ranks: undefined,
url: url,
rank: data.ranks.find(rank => rank[0] === date)?.[1],
date: new Date(date),
};
});
});
function render() {
const width = window.innerWidth;
const output = Plot.plot({
grid: true,
width,
height: 800,
color: {
legend: true,
},
y: {
reverse: true,
},
x: {
type: 'time'
},
marginLeft: 20,
marginRight: 10,
marks: [
Plot.lineY(normalized, {
x: "date",
y: "rank",
stroke: 'section',
strokeLinecap: 'butt',
strokeLinejoin: 'miter-clip',
z: 'url',
href: 'url',
strokeWidth: 10,
}),
Plot.tip(normalized, Plot.pointer({
x: "date",
y: "rank",
href: 'url',
frameAnchor: "top-left",
title: (d) => d.title + ' ' + d.section
}))
]});
chart.innerHTML = '';
output.addEventListener('click', (e) => {
if (e.target instanceof SVGPathElement && e.target.ariaDescription) {
window.open(e.target.ariaDescription);
}
});
chart.append(output);
}
render();
window.addEventListener('resize', debounce(() => {
render();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/** @jsxImportSource npm:hono/jsx */
import { camelCase } from "https://deno.land/x/case@2.2.0/mod.ts";
import { slug } from "https://deno.land/x/slug@v1.1.0/mod.ts";
import { BaseUnit, Fmt } from "https://esm.town/v/tmcw/BaseUnit";
import { getCookie } from "https://esm.town/v/tmcw/getCookie";
import { getMaterialIds } from "https://esm.town/v/tmcw/getMaterialIds";
import { getMaterials } from "https://esm.town/v/tmcw/getMaterials";
import { glasses } from "https://esm.town/v/tmcw/glasses";
import { materials } from "https://esm.town/v/tmcw/materials";
import { materialType } from "https://esm.town/v/tmcw/materialType";
import { Recipe } from "https://esm.town/v/tmcw/Recipe";
import { recipes } from "https://esm.town/v/tmcw/recipes";
import { setCookie } from "https://esm.town/v/tmcw/setCookie";
import { sort } from "https://esm.town/v/tmcw/sort";
import { styleSystem } from "https://esm.town/v/tmcw/styleSystem";
import { Context, Hono } from "npm:hono@3.8.1";
import { createContext, Fragment, useContext } from "npm:hono@3.8.1/jsx";
import { Ingredient } from "https://esm.town/v/tmcw/Ingredient";
// TODO: Deno doesn't have a pattern for this?
const app = new Hono();
const { styleRoute, StyleTag } = styleSystem();
function MaterialsList() {
const c = useContext(RequestContext);
const mats = getMaterialIds(c);
return (
<plank id="materials-list" hx-swap-oob="true">
<details open>
<summary>Materials</summary>
<div>
Select{" "}
<button hx-post="/material" hx-swap="none" name="all" value="true">
All
</button>{" "}
<button hx-post="/material" hx-swap="none" name="all" value="false">
None
</button>
</div>
{Object.values(materialType).map((group) => {
return (
<material-group>
<material-group-name>{group.name}</material-group-name>
{group.links.map(
(mat) => {
// Possibly wait until loaded to toggle fully?
return (
<form
hx-post="/material"
hx-trigger="change"
hx-swap="none"
>
<label>
<input type="hidden" name="name" value={mat.id} />
<input
type="checkbox"
checked={mats.has(mat.id)}
name="included"
/>
{mat.name}
</label>
</form>
);
},
)}
</material-group>
);
})}
</details>
</plank>
);
}
function RecipesList() {
const c = useContext(RequestContext);
const s = c?.req.param("slug");
const mats = getMaterials(c);
const names = new Set(mats.map((m) => m.name));
return (
<plank id="recipes-list" hx-swap-oob="true">
<details open>
<summary>Recipes</summary>
{sort(recipes, mats).map((recipe) => {
const thisSlug = slug(recipe.name);
// Possibly wait until loaded to toggle fully?
return (
<a
class={`recipe ${s === thisSlug ? "active" : ""}`}
href={`/recipe/${thisSlug}`}
hx-boost="true"
>
<name>
<img
width="16"
height="16"

Elysia example

This is how you can use Elysia with Val Town.

1
2
3
4
5
6
export const elysiaExample = async (req) => {
const { Elysia } = await import("https://esm.sh/elysia@0.7.15");
const app = new Elysia()
.get("/", () => "Hello Elysia");
return app.fetch(req);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// set by tmcw.big_story at 2023-07-21T13:27:35.553Z
export let big_stories_ranks = {
"https://www.nytimes.com/2023/07/15/nyregion/gilgo-beach-serial-killer-rex-heuermann.html": {
"title": "The Gilgo Beach Victims Were Always More Than Escorts",
"url": "https://www.nytimes.com/2023/07/15/nyregion/gilgo-beach-serial-killer-rex-heuermann.html",
"section": "nyregion",
"ranks": [[1689406048265, 1], [1689409648256, 1], [1689413248082, 1], [1689416848770, 4], [1689420449062, 4], [1689424047932, 4], [1689427649394, 4], [1689431249151, 4], [1689434848967, 4], [1689438448695, 4], [1689442049249, 4], [1689445647610, 4], [1
},
"https://www.nytimes.com/2023/07/17/us/georgia-supreme-court-trump-investigation.html": {
"title": "Georgia Supreme Court Rejects Trump Effort to Quash Investigation",
"url": "https://www.nytimes.com/2023/07/17/us/georgia-supreme-court-trump-investigation.html",
"section": "us",
"ranks": [[1689636450522, 7], [1689640050582, 7], [1689643651348, 8], [1689647250163, 8], [1689650849977, 8], [1689654450291, 8], [1689658049922, 8], [1689661650447, 7], [1689665250263, 7], [1689668850305, 7], [1689672451508, 7], [1689676050115, 7], [1
},
"https://www.nytimes.com/2023/07/17/us/congress-israel-democrats.html": {
"title": "In Congress, Democrats’ Rift Over Israel Flares on Eve of Herzog Visit",
"url": "https://www.nytimes.com/2023/07/17/us/congress-israel-democrats.html",
"section": "us",
"ranks": [[1689643651348, 1], [1689647250163, 1], [1689650849977, 1], [1689654450291, 1], [1689658049922, 1], [1689661650447, 1], [1689665250263, 1], [1689668850305, 1], [1689672451508, 1], [1689676050115, 2], [1689679650435, 5], [1689683250422, 5], [1
},
"https://www.nytimes.com/2023/07/18/world/middleeast/israel-protests-judicial-overhaul.html": {
"title": "Protesters Rally Across Israel in New Push Against Legal Overhaul",
"url": "https://www.nytimes.com/2023/07/18/world/middleeast/israel-protests-judicial-overhaul.html",
"section": "world",
"ranks": [[1689672451508, 2], [1689676050115, 3], [1689679650435, 4], [1689683250422, 4], [1689686850141, 4], [1689690449649, 6], [1689694050066, 6], [1689697649630, 6], [1689701250225, 6], [1689704850674, 8], [1689708451205, 8], [1689712050482, 8], [1
},
"https://www.nytimes.com/2023/07/18/world/europe/ukraine-world-war-ii.html": {
"title": "Wars Collide: The Enduring Legacy of World War II in Ukraine",
"url": "https://www.nytimes.com/2023/07/18/world/europe/ukraine-world-war-ii.html",
"section": "world",
"ranks": [[1689676050115, 0], [1689679650435, 3], [1689683250422, 3], [1689686850141, 3], [1689690449649, 5], [1689694050066, 5], [1689697649630, 5], [1689701250225, 5], [1689704850674, 6], [1689708451205, 6], [1689712050482, 6], [1689715650173, 6]]
},
"https://www.nytimes.com/2023/07/18/us/politics/biden-israel-netanyahu-herzog.html": {
"title": "Israeli President to Meet With Biden Amid U.S. Unease With Netanyahu",
"url": "https://www.nytimes.com/2023/07/18/us/politics/biden-israel-netanyahu-herzog.html",
"section": "us",
"ranks": [[1689676050115, 1], [1689679650435, 6], [1689683250422, 6], [1689686850141, 6], [1689690449649, 8], [1689694050066, 8], [1689704850674, 7], [1689708451205, 7], [1689712050482, 7], [1689715650173, 7], [1689719250747, 10], [1689722849730, 12],
},
"https://www.nytimes.com/2023/07/18/us/phoenix-heat-record.html": {
"title": "In Phoenix, 18 Days of Extreme Heat With No End in Sight",
"url": "https://www.nytimes.com/2023/07/18/us/phoenix-heat-record.html",
"section": "us",
"ranks": [[1689676050115, 4], [1689679650435, 0], [1689683250422, 0], [1689686850141, 0], [1689690449649, 2], [1689694050066, 3], [1689726449943, 4], [1689730049758, 4], [1689733650049, 5], [1689737249460, 5], [1689740850153, 5], [1689744450242, 5], [1
},
"https://www.nytimes.com/2023/07/18/world/asia/china-heat-kerry.html": {
"title": "As China Bakes in Record Heat, Kerry Presses Beijing on Climate Change",
"url": "https://www.nytimes.com/2023/07/18/world/asia/china-heat-kerry.html",
"section": "world",
"ranks": [[1689676050115, 5], [1689679650435, 1], [1689683250422, 1], [1689686850141, 1], [1689690449649, 3]]
},
"https://www.nytimes.com/article/trump-classified-documents-evidence.html": {
"title": "Trump Documents Hearing Could Set Off Lengthy Fight Over Classified Evidence",
"url": "https://www.nytimes.com/article/trump-classified-documents-evidence.html",
"section": "us",
"ranks": [[1689676050115, 6], [1689679650435, 7], [1689683250422, 7], [1689686850141, 7], [1689690449649, 0], [1689694050066, 1], [1689697649630, 1], [1689701250225, 1], [1689704850674, 1]]
},
"https://www.nytimes.com/2023/07/18/magazine/wikipedia-ai-chatgpt.html": {
"title": "Wikipedia’s Moment of Truth",
"url": "https://www.nytimes.com/2023/07/18/magazine/wikipedia-ai-chatgpt.html",
"section": "magazine",
"ranks": [[1689676050115, 8], [1689679650435, 9], [1689683250422, 9], [1689686850141, 9], [1689690449649, 9], [1689694050066, 10]]
},
"https://www.nytimes.com/2023/07/18/technology/openai-chatgpt-facial-recognition.html": {
"title": "OpenAI Worries About What Its Chatbot Will Say About People’s Faces",
"url": "https://www.nytimes.com/2023/07/18/technology/openai-chatgpt-facial-recognition.html",
"section": "technology",
"ranks": [[1689676050115, 9], [1689679650435, 10], [1689683250422, 10], [1689686850141, 10], [1689690449649, 10], [1689694050066, 11]]
},
"https://www.nytimes.com/2023/07/18/business/media/ai-advertising.html": {
"title": "A Blessing and a Boogeyman: Advertisers Warily Embrace A.I.",
"url": "https://www.nytimes.com/2023/07/18/business/media/ai-advertising.html",
"section": "business",
"ranks": [[1689676050115, 10], [1689679650435, 11], [1689683250422, 11], [1689686850141, 11], [1689690449649, 11], [1689694050066, 12]]
},
"https://www.nytimes.com/2023/07/18/world/asia/india-diaspora.html": {
"title": "Modi and India’s Diaspora: A Complex Love Affair Making Global Waves",
"url": "https://www.nytimes.com/2023/07/18/world/asia/india-diaspora.html",
"section": "world",
"ranks": [[1689676050115, 11], [1689679650435, 12], [1689683250422, 12], [1689686850141, 12], [1689690449649, 12], [1689697649630, 19]]
},
"https://www.nytimes.com/2023/07/18/world/africa/nelson-mandela-day-south-africa.html": {
"title": "Mandela Goes From Hero to Scapegoat as South Africa Struggles",
"url": "https://www.nytimes.com/2023/07/18/world/africa/nelson-mandela-day-south-africa.html",
"section": "world",
"ranks": [[1689676050115, 13], [1689679650435, 14], [1689683250422, 15], [1689686850141, 15], [1689690449649, 15], [1689694050066, 15]]
},
"https://www.nytimes.com/2023/07/18/world/australia/yiddish-melbourne-australia.html": {
"title": "A Yiddish Haven Thrives in Australia",
"url": "https://www.nytimes.com/2023/07/18/world/australia/yiddish-melbourne-australia.html",
"section": "world",
"ranks": [[1689676050115, 14], [1689679650435, 15], [1689683250422, 16], [1689686850141, 16], [1689690449649, 16], [1689694050066, 16]]
},
"https://www.nytimes.com/2023/07/18/opinion/supreme-court-big-decisions.html": {
"title": "The 2023 SCOTUS Awards",
"url": "https://www.nytimes.com/2023/07/18/opinion/supreme-court-big-decisions.html",
"section": "opinion",
"ranks": [[1689676050115, 15], [1689679650435, 16], [1689683250422, 17], [1689686850141, 17], [1689690449649, 17], [1689694050066, 17], [1689697649630, 13], [1689701250225, 15], [1689704850674, 16], [1689708451205, 16], [1689712050482, 16], [1689715650
},
"https://www.nytimes.com/2023/07/18/opinion/universal-health-care.html": {
"title": "We’re Already Paying for Universal Health Care. Why Don’t We Have It?",

Quick poll

https://tmcw-poll.express.val.run

This val, along with a val called poll_results and a val called poll_options, lets you conduct a little poll using just Val Town! With the express API, this exposes a web interface in which people can click an option and vote. We make sure everyone only votes once by setting a cookie in their browser.

This uses htmx to make the voting experience slick, Tailwind to style the UI, and htm to generate HTML for the pages.

If you want to use this yourself, fork this val, the poll_results val, and the poll_options val, and customize the options list. You can delete the existing results as well to clear the data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { poll_results } from "https://esm.town/v/tmcw/poll_results";
import { poll_options } from "https://esm.town/v/tmcw/poll_options";
export let poll = async (req: express.Request, res: express.Response) => {
const { default: htm } = await import("npm:htm");
const { default: vhtml } = await import("npm:vhtml");
const cookies = await import("https://deno.land/std@0.193.0/http/cookie.ts");
const html = htm.bind(vhtml);
const head = html`<head>
<title>Poll</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.2"></script>
</head>`;
const ChartPie =
html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4">
<path d="M12 9a1 1 0 01-1-1V3c0-.553.45-1.008.997-.93a7.004 7.004 0 015.933 5.933c.078.547-.378.997-.93.997h-5z" />
<path d="M8.003 4.07C8.55 3.992 9 4.447 9 5v5a1 1 0 001 1h5c.552 0 1.008.45.93.997A7.001 7.001 0 012 11a7.002 7.002 0 016.003-6.93z" />
</svg>
`;
const formClass = "p-2 group min-h-screen animate-all bg-pink-50";
function parseCookies(cookie: string) {
const out: Record<string, string> = {};
const c = cookie.split(";");
for (const kv of c) {
const [cookieKey, ...cookieVal] = kv.split("=");
const key = cookieKey.trim();
out[key] = cookieVal.join("=");
}
return out;
}
function getResults(hasVoted: boolean) {
return html`<html>
${head}
<body>
<form hx-post="/" class=${formClass}>
<h1 class="text-sm font-bold uppercase text-pink-900 pb-2 flex items-center gap-x-2">
${ChartPie}
poll results
</h1>
<div class='space-y-2'>
${
poll_options.map((option, i) => {
const count = poll_results.filter((result) => {
return result.value === option;
}).length;
const percentage = (count / poll_results.length) *
100;
return html`<div class='p-2 rounded-md block text-sm
bg-pink-100 border border-pink-500
text-pink-700 overflow-hidden
relative flex justify-between'>
<div class='absolute top-0 left-0 bottom-0 bg-pink-200 z-0'
style=${`width: ${percentage}%`} />
<div class='z-1 relative'>${option}</div>
<div class='z-1 relative'>${count.toLocaleString("en-US")} / ${
(percentage).toFixed(0)
}%</div>
</div>`;
})
}</div>
<div class='text-center pt-2'>
${
hasVoted
? html`<div class='text-pink-300 cursor-not-allowed text-xs'>You've voted</div>`
: html`<a href='/' class='text-pink-500 underline hover:text-pink-700 text-xs'>Vote</a>`
}</div>
</form>
</body>
</html>`;
}
switch (req.method) {
case "POST": {
const value = req.body;
if (!("choice" in value)) {
return res.end("Bad input");
}
poll_results.push({
value: poll_options[value.choice],
time: (new Date()).toISOString(),
});
res.set("Set-Cookie", "voted=true; Path=/; HttpOnly");
res.end(getResults(true));
return;
}
default: {
const cookies = parseCookies(req.get("cookie"));
const hasVoted = "voted" in cookies;
if ("results" in req.query || hasVoted) {
return res.end(getResults(hasVoted));
}
else {
return res.end(html`<html>
${head}
<body>
<form hx-post="/"
class=${formClass}

HTTP Val Mocking example

This is an example of mocking an HTTP val. Let's say that you're developing a val that responds to a POST request. That's tricky to debug and develop in the Browser preview tab, which just shows a GET request. But you can do it, because with web-standard Request & Response objects, requests and responses are values, and we can create those values ourselves.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* This method expects to receive a POST request
* with a body, which is a JSON object with a name
* member.
*/
export const httpHandler = async function(req: Request): Promise<Response> {
if (req.method !== "POST") return new Response("Bad request: only POST allowed", { status: 400 });
const body = await req.json();
return Response.json({ hello: body.name });
};
/**
* This "mocks out" the request: so we're sending a POST request with a JSON
* body here. We can tweak everything about this request - we're the ones
* creating it.
*
* And we can also preview the result of this handler in the standard Browser
* preview. In short, in this chunk of code, we're exporting a _different_
* HTTP handler, which ignores the incoming request and provides one of its
* own, and then sends the resulting response.
*
* When you're ready to deploy and done with testing, you can just
* comment out this block of code.
*/
export default () =>
httpHandler(
new Request("https://tmcw-httpmockingexample.web.val.run/", {
body: JSON.stringify({ name: "Peter" }),
headers: {
"Content-Type": "application/json",
},
method: "POST",
}),
);