Back to packages list

Vals using lodash-es

Description from the NPM package:
Lodash exported as ES modules.
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
import { Base64 } from "https://cdn.jsdelivr.net/npm/js-base64@3.7.7/base64.mjs";
import { debounce } from "https://esm.sh/lodash-es";
const editor = document.getElementById("editor");
const preview = document.getElementById("preview") as HTMLIFrameElement;
const printBtn = document.getElementById("print-btn") as HTMLButtonElement;
const copyBtn = document.getElementById("copy-btn") as HTMLButtonElement;
const formatBtn = document.getElementById("format-btn") as HTMLButtonElement;
const languageSelector = document.getElementById("languages") as HTMLSelectElement;
const currencySelector = document.getElementById("currencies") as HTMLSelectElement;
async function updatePreview() {
try {
const code = JSON.parse(editor.code);
const language = languageSelector.value;
const currency = currencySelector.value;
const encoded = Base64.encode(JSON.stringify(code));
preview.src = `${window.location.origin}/invoice/${encoded}?language=${language}&currency=${currency}`;
} catch (_) {
}
}
const debouncedUpdatePreview = debounce(updatePreview, 500, {});
editor.addEventListener("code-change", (e: CustomEvent) => {
debouncedUpdatePreview();
});
currencySelector.addEventListener("change", () => {
debouncedUpdatePreview();
});
languageSelector.addEventListener("change", () => {
debouncedUpdatePreview();
});
printBtn.addEventListener("click", (e: CustomEvent) => {
window.open(preview.src);
});
copyBtn.addEventListener("click", async (e: CustomEvent) => {
await navigator.clipboard.writeText(editor.code);
});
formatBtn.addEventListener("click", async (e: CustomEvent) => {
const invoice = JSON.parse(editor.code);
editor.code = JSON.stringify(invoice, null, 2);
});
updatePreview();

SSR React Mini & SQLite Todo App

This Todo App is server rendered and client-hydrated React. This architecture is a lightweight alternative to NextJS, RemixJS, or other React metaframeworks with no compile or build step. The data is saved server-side in Val Town SQLite.

demo

SSR React Mini Framework

This "framework" is currently 44 lines of code, so it's obviously not a true replacement for NextJS or Remix.

The trick is client-side importing the React component that you're server rendering. Val Town is uniquely suited for this trick because it both runs your code server-side and exposes vals as modules importable by the browser.

The tricky part is making sure that server-only code doesn't run on the client and vice-versa. For example, because this val colocates the server-side loader and action with the React component we have to be careful to do all server-only imports (ie sqlite) dynamically inside the loader and action, so they only run server-side.

Readme
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 https://esm.sh/react */
import { zip } from "https://esm.sh/lodash-es";
import { useEffect, useState } from "https://esm.sh/react@18.2.0";
import codeOnValTown from "https://esm.town/v/andreterron/codeOnValTown?v=46";
import { Form, hydrate } from "https://esm.town/v/stevekrouse/ssr_react_mini?v=75";
export async function loader(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const [, { columns, rows }] = await sqlite.batch([
`CREATE TABLE IF NOT EXISTS todos2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
text TEXT NOT NULL
)`,
`select * from todos2`,
]);
const initialTodos = rows.map(row => Object.fromEntries(zip(columns, row)));
return { initialTodos, initialLogs: [`Server rendered`] };
}
export async function action(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const formData = await req.formData();
if (req.method === "POST") {
const text = formData.get("text") as string;
const [, { rows }] = await sqlite.batch([{
sql: `insert into todos2(text) values (?)`,
args: [text],
}, "select last_insert_rowid()"]);
return Response.json({ id: rows[0][0], text });
}
else if (req.method === "PUT") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
const { rows } = await sqlite.execute({ sql: `select completed_at from todos2 where id = ?`, args: [id] });
const completed_at = rows[0][0];
const new_completed_at = completed_at ? null : new Date() as any;
await sqlite.execute({ sql: `update todos2 set completed_at = ? where id = ?`, args: [new_completed_at, id] });
}
else if (req.method === "DELETE") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
await sqlite.execute({ sql: `delete from todos2 where id = ?`, args: [id] });
}
return Response.json("OK");
}
export function Component({ initialTodos, initialLogs }) {
const [todos, setTodos] = useState(initialTodos);
const [logs, setLogs] = useState(initialLogs);
const [newTodo, setNewTodo] = useState("");
const addLog = log => setLogs([...logs, log]);
useEffect(() => addLog(`Client rendered`), []);
function addTodo() {
setTodos([...todos, { text: newTodo }]);
setNewTodo("");
return async (resp: Response) => {
const { text, id } = await resp.json();
setTodos([...todos, { text, id }]); // ok to add again because todos is stale
addLog(`Got ${resp.ok ? "OK" : "Error"} from server for adding todo`);
};
}
function toggleTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.map(t => t.id === id ? { ...t, completed_at: t.completed_at ? null : Date.now() } : t));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for toggling todo`);
}
function deleteTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.filter(t => t.id !== id));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for deleting todo`);
}
return (
<html>
<head>
<title>Todo List</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com" />
</head>
<body className="max-w-md mx-auto p-10">
<h2 className="text-xl font-bold pb-2">Todo list</h2>
<ol className="pl-2 max-w-64">
{todos.map((todo) => (
<li key={todo.id} className={`flex justify-between ${todo.completed_at ? "line-through" : ""}`}>
<div className="flex">
<Form action="put" className="mr-2" onSubmit={toggleTodo}>
<input name="id" value={todo.id} type="hidden" />
<button>{todo.completed_at ? "✅" : "o"}</button>
</Form>
{todo.text}
</div>
<Form action="delete" onSubmit={deleteTodo}>
<input name="id" value={todo.id} type="hidden" />
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@3/jsx */
import { sqlite } from "https://esm.town/v/std/sqlite";
import Layout from "https://esm.town/v/stevekrouse/dateme_layout";
import { zip } from "npm:lodash-es";
function absoluteURL(url) {
if (url.startsWith("http://") || url.startsWith("https://"))
return url;
else return "https://" + url;
}
let headers = [
"Name",
"Age",
"Gender",
"InterestedIn",
"Style",
"Location",
"LocationFlexibility",
"Contact",
"LastUpdated",
];
let linkClass = "text-sky-600 hover:text-sky-500";
function httpsIfy(url: string) {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return `https://${url}`;
}
return url;
}
function renderCell(header, row) {
let data = row[header];
if (header === "Name") {
return <a class={linkClass} href={httpsIfy(row["Profile"])} target="_blank">{data}</a>;
} else if (Array.isArray(data)) {
return data.map(d => <span class="p-1 m-1 border rounded-md">{d}</span>);
} else if (header === "LastUpdated") {
return new Date(data).toISOString().split("T")[0];
}
else {
return data;
}
}
export default async function Browse(c) {
const url = new URL(c.req.url);
const search = {
gender: url.searchParams.get("gender"),
desiredGender: url.searchParams.get("desired-gender"),
minAge: url.searchParams.get("min-age")?.length ? url.searchParams.get("min-age") : 0,
maxAge: url.searchParams.get("max-age")?.length ? url.searchParams.get("max-age") : 100,
location: url.searchParams.get("location"),
};
let { columns, rows } = await sqlite.execute({
sql: `select * from datemedocs
where
Location like ?
and Age >= ?
and Age <= ?
and Gender like ?
and InterestedIn like ?
order by LastUpdated desc
`,
args: [
"%" + (search.location ?? "") + "%",
search.minAge,
search.maxAge,
"%" + (search.desiredGender ?? "") + "%",
"%" + (search.gender ?? "") + "%",
],
});
const profiles = rows.map(row =>
Object.fromEntries(zip(
columns,
row.map(d => {
try {
return JSON.parse(d);
} catch (_) {
return d;
}
}),
))
);
return c.html(
<Layout activeTab={new URL(c.req.url).pathname}>
<div class="max-w-md mx-auto p-10">
<div>
You are <b>{search.gender?.length ? search.gender : "any gender"}</b>{" "}looking for{" "}
<b>{search.desiredGender?.length ? search.desiredGender : "any gender"}</b>{" "}bewteen the ages of{" "}
<b>{search.minAge}</b>{" "}and{" "}<b>{search.maxAge}</b> in{" "}
<b>{search.location?.length ? search.location : "the world"}</b>. There are{" "}<b>{profiles.length}</b>{" "}
profiles for you.
</div>
<div>

SQLite Table Export Utils

This allows for a val.town-hosted SQLite table to be exported as:

  • JSON (Record<string, unknown>[])
  • Arrow IPC (Uint8Array)
  • TODO: Others?

This can then be used by a HTTP endpoint, like so:

Create valimport { exportSQLiteTable, SQLiteTableExportFormat } from "https://esm.town/v/rlesser/sqliteTableExportUtils"; export default async function(req: Request): Promise<Response> { const tableName = new URL(req.url).searchParams.get("table"); if (!tableName) { return new Response("Table name is required", { status: 400 }); } const format = (new URL(req.url).searchParams.get("format") || "arrowIPC") as SQLiteTableExportFormat; const data = await exportSQLiteTable(tableName, format); if (data instanceof Uint8Array) { return new Response(data, { headers: { "Content-Type": "application/octet-stream" }, }); } else { return Response.json(data); } }

TODO

  • Specify limit and offset of export, for pagination
  • Smart assessment of if the export is going to be over the val.town limit of 10MB, adjust to paginated of so.
  • Support other export formats.

PRs welcome!

Readme
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
import { sqlite } from "https://esm.town/v/std/sqlite";
import { type ResultSet } from "npm:@libsql/client";
import { DataType, Float64, Int32, Table, tableToIPC, Utf8, vectorFromArray } from "npm:apache-arrow";
import { zip } from "npm:lodash-es@4.17.21";
// Function to map SQLite data types to Apache Arrow data types
function sqliteTypeToArrowType(sqliteType: string): DataType {
switch (sqliteType.toUpperCase()) {
case "INTEGER":
case "NUMBER":
return new Int32();
case "TEXT":
return new Utf8();
case "REAL":
return new Float64();
// Add more mappings as needed
default:
return new Utf8(); // Default or throw error for unsupported types
}
}
export type SQLiteTableExportFormat = "json" | "arrowIPC";
// From https://www.val.town/v/pomdtr/sql zip
export function resultSetToJSON(res: ResultSet) {
return res.rows.map(row => Object.fromEntries(zip(res.columns, row)));
}
export function resultSetToArrowTable(res: ResultSet) {
const tableData = {};
res.columns.forEach((column, i) => {
const data = res.rows.map(row => row[i]);
tableData[column] = vectorFromArray(data, sqliteTypeToArrowType(res.columnTypes[i]));
});
return new Table(tableData);
}
export async function exportSQLiteTable(
tableName: string,
format: SQLiteTableExportFormat,
limit: number | null,
offset: number | null,
): Promise<Record<string, unknown>[] | Uint8Array> {
// Read data from the SQLite table
const data = await sqlite.execute(`
SELECT * FROM ${tableName} LIMIT ${limit || -1} OFFSET ${offset || 0}
`) as ResultSet;
const rows = data.rows;
// Dynamically read the schema of the table
const schemaInfo = await sqlite.execute(`PRAGMA table_info(${tableName})`) as ResultSet;
const columns = schemaInfo.rows.map(col => ({ name: String(col[1]), type: sqliteTypeToArrowType(String(col[2])) }));
// If json, output this right away
if (format == "json") {
return resultSetToJSON(data);
}
const arrowTable = resultSetToArrowTable(data);
return tableToIPC(arrowTable);
}
1
2
3
4
import _ from "npm:lodash-es";
let numbers = _.range(10);
console.log(numbers.map(n => n * 2));

SSR React Mini & SQLite Todo App

This Todo App is server rendered and client-hydrated React. This architecture is a lightweight alternative to NextJS, RemixJS, or other React metaframeworks with no compile or build step. The data is saved server-side in Val Town SQLite.

demo

SSR React Mini Framework

This "framework" is currently 44 lines of code, so it's obviously not a true replacement for NextJS or Remix.

The trick is client-side importing the React component that you're server rendering. Val Town is uniquely suited for this trick because it both runs your code server-side and exposes vals as modules importable by the browser.

The tricky part is making sure that server-only code doesn't run on the client and vice-versa. For example, because this val colocates the server-side loader and action with the React component we have to be careful to do all server-only imports (ie sqlite) dynamically inside the loader and action, so they only run server-side.

Readme
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 https://esm.sh/react */
import { zip } from "https://esm.sh/lodash-es";
import { useEffect, useState } from "https://esm.sh/react@18.2.0";
import codeOnValTown from "https://esm.town/v/andreterron/codeOnValTown?v=46";
import { Form, hydrate } from "https://esm.town/v/stevekrouse/ssr_react_mini?v=75";
export async function loader(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const [, { columns, rows }] = await sqlite.batch([
`CREATE TABLE IF NOT EXISTS todos2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
text TEXT NOT NULL
)`,
`select * from todos2`,
]);
const initialTodos = rows.map(row => Object.fromEntries(zip(columns, row)));
return { initialTodos, initialLogs: [`Server rendered`] };
}
export async function action(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const formData = await req.formData();
if (req.method === "POST") {
const text = formData.get("text") as string;
const [, { rows }] = await sqlite.batch([{
sql: `insert into todos2(text) values (?)`,
args: [text],
}, "select last_insert_rowid()"]);
return Response.json({ id: rows[0][0], text });
}
else if (req.method === "PUT") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
const { rows } = await sqlite.execute({ sql: `select completed_at from todos2 where id = ?`, args: [id] });
const completed_at = rows[0][0];
const new_completed_at = completed_at ? null : new Date() as any;
await sqlite.execute({ sql: `update todos2 set completed_at = ? where id = ?`, args: [new_completed_at, id] });
}
else if (req.method === "DELETE") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
await sqlite.execute({ sql: `delete from todos2 where id = ?`, args: [id] });
}
return Response.json("OK");
}
export function Component({ initialTodos, initialLogs }) {
const [todos, setTodos] = useState(initialTodos);
const [logs, setLogs] = useState(initialLogs);
const [newTodo, setNewTodo] = useState("");
const addLog = log => setLogs([...logs, log]);
useEffect(() => addLog(`Client rendered`), []);
function addTodo() {
setTodos([...todos, { text: newTodo }]);
setNewTodo("");
return async (resp: Response) => {
const { text, id } = await resp.json();
setTodos([...todos, { text, id }]); // ok to add again because todos is stale
addLog(`Got ${resp.ok ? "OK" : "Error"} from server for adding todo`);
};
}
function toggleTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.map(t => t.id === id ? { ...t, completed_at: t.completed_at ? null : Date.now() } : t));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for toggling todo`);
}
function deleteTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.filter(t => t.id !== id));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for deleting todo`);
}
return (
<html>
<head>
<title>Todo List</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com" />
</head>
<body className="max-w-md mx-auto p-10">
<h2 className="text-xl font-bold pb-2">Todo list</h2>
<ol className="pl-2 max-w-64">
{todos.map((todo) => (
<li key={todo.id} className={`flex justify-between ${todo.completed_at ? "line-through" : ""}`}>
<div className="flex">
<Form action="put" className="mr-2" onSubmit={toggleTodo}>
<input name="id" value={todo.id} type="hidden" />
<button>{todo.completed_at ? "✅" : "o"}</button>
</Form>
{todo.text}
</div>
<Form action="delete" onSubmit={deleteTodo}>
<input name="id" value={todo.id} type="hidden" />

Date Me Directory RSS Feed

An RSS feed for the https://dateme.directory.

Readme
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
import { sqlite } from "https://esm.town/v/std/sqlite?v=6";
import { dataToRSS } from "https://esm.town/v/stevekrouse/dataToRSS";
import { zip } from "npm:lodash-es";
export async function dateMeRSS(req: Request) {
let { columns, rows } = await sqlite.execute(`select * from datemedocs order by LastUpdated desc limit 20`);
const profiles = rows.map(row =>
Object.fromEntries(zip(
columns,
row.map(d => {
try {
return JSON.parse(d);
} catch (_) {
return d;
}
}),
))
);
let data = profiles.map((p) => {
let { Name, Profile, LastUpdated, ...rest } = p;
return {
title: Name,
link: Profile,
pubDate: new Date(LastUpdated),
description: Object.entries(rest).filter(([, v]) => v).map(([k, v]) => k + ": " + v).join(
"\n ",
),
};
}).sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
return new Response(
dataToRSS(data, {
title: "Date Me Directory RSS",
link: "https://dateme.directory",
rssLink: "https://dateme.directory/rss.xml",
}),
{
headers: {
"Content-type": "application/rss+xml",
},
},
);
}

SQL Template Tag

Port of blakeembrey/sql-template-tag for usage in val.town.

Usage

import { sqlite } from "https://esm.town/v/std/sqlite"
import { sql, zip } from "https://esm.town/v/pomdtr/sql"

const query = sql`SELECT * FROM books WHERE author = ${author}`;
console.log(query.sql) // => "SELECT * FROM books WHERE author = ?"
console.log(query.args) // => [author]
const res = await sqlite.execute(query)
console.table(zip(res))

For advanced usage (ex: nesting queries), refer to the project readme.

Readme
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 type { InValue, sqlite } from "https://esm.town/v/std/sqlite";
import { zip as zip2 } from "npm:lodash-es@4.17.21";
/**
* Supported value or SQL instance.
*/
type RawValue = InValue | Sql;
/**
* A SQL instance can be nested within each other to build SQL strings.
*/
class Sql {
readonly values: InValue[];
readonly strings: string[];
constructor(rawStrings: readonly string[], rawValues: readonly RawValue[]) {
if (rawStrings.length - 1 !== rawValues.length) {
if (rawStrings.length === 0) {
throw new TypeError("Expected at least 1 string");
}
throw new TypeError(
`Expected ${rawStrings.length} strings to have ${rawStrings.length - 1} values`,
);
}
const valuesLength = rawValues.reduce<number>(
(len, value) => len + (value instanceof Sql ? value.values.length : 1),
0,
);
this.values = new Array(valuesLength);
this.strings = new Array(valuesLength + 1);
this.strings[0] = rawStrings[0];
// Iterate over raw values, strings, and children. The value is always
// positioned between two strings, e.g. `index + 1`.
let i = 0,
pos = 0;
while (i < rawValues.length) {
const child = rawValues[i++];
const rawString = rawStrings[i];
// Check for nested `sql` queries.
if (child instanceof Sql) {
// Append child prefix text to current string.
this.strings[pos] += child.strings[0];
let childIndex = 0;
while (childIndex < child.values.length) {
this.values[pos++] = child.values[childIndex++];
this.strings[pos] = child.strings[childIndex];
}
// Append raw string to current string.
this.strings[pos] += rawString;
} else {
this.values[pos++] = child;
this.strings[pos] = rawString;
}
}
}
get sql() {
const len = this.strings.length;
let i = 1;
let value = this.strings[0];
while (i < len) value += `?${this.strings[i++]}`;
return value;
}
get args() {
return this.values;
}
toJSON() {
return {
sql: this.sql,
args: this.values,
};
}
}
/**
* Create a SQL query for a list of values.
*/
export function join(
values: readonly RawValue[],
separator = ",",
prefix = "",
suffix = "",
) {
if (values.length === 0) {
throw new TypeError(
"Expected `join([])` to be called with an array of multiple elements, but got an empty array",
);
}
return new Sql(

Password Auth Middleware

Protect your vals behind a password. Use session cookies to persist authentication.

6ed0648ae8813e958dbe79468572cb52f578239c0fae55857a13660beebdc5fd.png

Demo

See @pomdtr/password_auth_test

Usage

If you want to use an api token to authenticate:

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); });

Or if you prefer to use a string:

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); }, { password: Deno.env.get("VAL_PASSWORD") });

You can also set multiple ones

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); }, { password: [Deno.env.get("VAL_PASSWORD"), Deno.env.get("STEVE_PASSWORD")] });

Note that authenticating using your api token remain an option even after setting a password.

TODO

  • allow to authenticate using a val town token
  • add a way to send an email to ask a password from the val owner
  • automatically extend the session
  • automatically remove expired sessions

FAQ

How to sign out ?

Navigate to <your-site>/signout.

Readme
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 { deleteCookie, getCookies, setCookie } from "https://deno.land/std/http/cookie.ts";
import { inferRequestVal } from "https://esm.town/v/andreterron/inferRequestVal?v=2";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { html } from "https://esm.town/v/stevekrouse/html?v=5";
import { zip } from "npm:lodash-es";
import { nanoid } from "npm:nanoid";
type Session = {
id: string;
expiresAt: number;
};
async function createSessionTable(tableName: string) {
await sqlite.execute(`CREATE TABLE ${tableName} (
id TEXT NOT NULL PRIMARY KEY,
expires_at INTEGER NOT NULL,
val_slug STRING NOT NULL
);`);
}
async function createSession(tableName: string, valSlug: string): Promise<Session> {
try {
const expires_at = new Date();
expires_at.setDate(expires_at.getDate() + 7);
const session: Session = { id: nanoid(), expiresAt: expires_at.getTime() };
await sqlite.execute({
sql: `INSERT INTO ${tableName} (id, val_slug, expires_at) VALUES (?, ?, ?)`,
args: [session.id, valSlug, session.expiresAt],
});
return session;
} catch (e) {
if (e.message.includes("no such table")) {
await createSessionTable(tableName);
return createSession(tableName, valSlug);
}
throw e;
}
}
async function getSession(tableName: string, sessionID: string, valSlug: string): Promise<Session> {
try {
const { rows, columns } = await sqlite.execute({
sql: `SELECT * FROM ${tableName} WHERE id = ? AND val_slug = ?`,
args: [sessionID, valSlug],
});
if (rows.length == 0) {
return null;
}
return Object.fromEntries(zip(columns, rows.at(0))) as Session;
} catch (e) {
if (e.message.includes("no such table")) {
return null;
}
throw e;
}
}
async function fetchUser(token: string): Promise<{ id: string }> {
const resp = await fetch("https://api.val.town/v1/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (resp.status !== 200) {
throw new Error("Could not fetch user");
}
return resp.json();
}
async function verifyApiToken(token: string) {
try {
const [currentUser, requestUser] = await Promise.all([fetchUser(Deno.env.get("valtown")), fetchUser(token)]);
return currentUser.id == requestUser.id;
} catch (_) {
return false;
}
}
const loginPage = (handle) =>
`<html>
<head>
<link rel="icon" href="https://fav.farm/🔒" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
</head>
<body style="display: flex; justify-content: center; align-items: center;">
<article>
<p>This val website is <a href="https://www.val.town/v/pomdtr/password_auth">protected by a password</a>.</p>
<p>If you are <a href="https://val.town/u/${handle}">@${handle}</a>, you can access it using an <a href="https://www.val.town/settings/api">API token</a>.</p>
<p>If not, you'll need to contact the author for access.</p>
<footer>
<form method="POST" style="margin-block-end: 0em;">
<fieldset role="group" style="margin-bottom: 0em;">
<input id="password" placeholder="Password" name="password" type="password" />
<input type="submit" value="Sign In"/>

Email Auth for Val Town

⚠️ Require a pro account (needed to send email to users)

Usage

Create an http server, and wrap it in the emailAuth middleware.

Create valexport default emailAuth((req, ctx) => { return new Response(`your mail is ${ctx.email}`); });

When an user access the val, he will need to input his mail, then confirm it through a confirmation code.

You can limit how can access your vals through an allowList:

Create valexport default emailAuth((req, ctx) => { return new Response(`your mail is ${ctx.email}`); }, { allowList: ["steve@val.town"] });

If someone tries to access your val but is not in the allowlist, he will be blocked.

If you want to allow user to request for access, you can mix allowList with allowSignup:

Create valexport default emailAuth((req, ctx) => { return new Response(`your mail is ${ctx.email}`); }, { allowList: ["steve@val.town"], allowSignup: true });

Each time a new user not present in the allowList try to login to a val, you will receive an email containing:

  • the email of the user trying to log in
  • the name of the val the he want to access

You can then just add the user to your whitelist to allow him in (and the user will not need to confirm his email again) !

Tips

If you don't want to put your email in clear text, you can just use an env variable:

Create valexport default emailAuth((req, ctx) => { return new Response(`your mail is ${ctx.email}`); }, { allowList: [Deno.env.get("email")] });

Or just setup a forward val (see @pomdtr/inbox):

Create valexport default emailAuth((req, ctx) => { return new Response(`your mail is ${ctx.email}`); }, { allowList: ["pomdtr.inbox@valtown.email"] });

TODO

  • Add expiration for verification codes and session tokens
  • use links instead of code for verification
  • improve errors pages
Readme
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 { deleteCookie, getCookies, setCookie } from "https://deno.land/std/http/cookie.ts";
import { inferRequestVal } from "https://esm.town/v/andreterron/inferRequestVal?v=2";
import { email as sendEmail } from "https://esm.town/v/std/email?v=11";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { html } from "https://esm.town/v/stevekrouse/html?v=5";
import { zip } from "npm:lodash-es";
import { nanoid } from "npm:nanoid";
import { createDate, TimeSpan } from "npm:oslo";
import { alphabet, generateRandomString } from "npm:oslo/crypto";
type Session = {
id: string;
email: string;
expiresAt: number;
};
async function createSessionTable(sessionTableName: string) {
await sqlite.execute(`CREATE TABLE ${sessionTableName} (
id TEXT NOT NULL PRIMARY KEY,
expires_at INTEGER NOT NULL,
email STRING NOT NULL,
val_slug STRING NOT NULL
);`);
}
async function createCodeTable(tableName: string) {
await sqlite.execute(`CREATE TABLE ${tableName} (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
code STRING NOT NULL,
email STRING NOT NULL,
expires_at INTEGER NOT NULL
)`);
}
async function generateEmailVerificationCode(tableName, email: string): Promise<string> {
try {
await sqlite.execute({ sql: `DELETE FROM ${tableName} WHERE email = ?`, args: [email] });
const code = generateRandomString(8, alphabet("0-9"));
const expires_at = createDate(new TimeSpan(5, "m")); // 5 minutes
await sqlite.execute({
sql: `INSERT INTO ${tableName} (email, code, expires_at) VALUES (?, ?, ?)`,
args: [email, code, expires_at.getTime() / 1000],
});
return code;
} catch (e) {
if (e.message.includes("no such table")) {
await createCodeTable(tableName);
return generateEmailVerificationCode(tableName, email);
}
throw e;
}
}
async function createSession(tableName: string, valSlug: string, email: string): Promise<Session> {
try {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
const sessionID = nanoid();
await sqlite.execute({
sql: `INSERT INTO ${tableName} (id, val_slug, expires_at, email) VALUES (?, ?, ?, ?)`,
args: [sessionID, valSlug, expiresAt.getTime() / 1000, email],
});
return {
id: sessionID,
email,
expiresAt: expiresAt.getTime(),
};
} catch (e) {
if (e.message.includes("no such table")) {
await createSessionTable(tableName);
return createSession(tableName, valSlug, email);
}
throw e;
}
}
async function getSession(tableName: string, sessionID: string, valSlug: string): Promise<Session> {
try {
const { rows, columns } = await sqlite.execute({
sql: `SELECT * FROM ${tableName} WHERE id = ? AND val_slug = ?`,
args: [sessionID, valSlug],
});
if (rows.length == 0) {
return null;
}
return Object.fromEntries(zip(columns, rows.at(0))) as Session;
} catch (e) {
if (e.message.includes("no such table")) {
return null;
}
throw e;
}
}

Password Auth Middleware

Protect your vals behind a password. Use session cookies to persist authentication.

6ed0648ae8813e958dbe79468572cb52f578239c0fae55857a13660beebdc5fd.png

Demo

See @pomdtr/password_auth_test

Usage

If you want to use an api token to authenticate:

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); });

Or if you prefer to use a string:

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); }, { password: Deno.env.get("VAL_PASSWORD") });

You can also set multiple ones

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); }, { password: [Deno.env.get("VAL_PASSWORD"), Deno.env.get("STEVE_PASSWORD")] });

Note that authenticating using your api token remain an option even after setting a password.

TODO

  • allow to authenticate using a val town token
  • add a way to send an email to ask a password from the val owner
  • automatically extend the session
  • automatically remove expired sessions

FAQ

How to sign out ?

Navigate to <your-site>/signout.

Readme
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 { deleteCookie, getCookies, setCookie } from "https://deno.land/std/http/cookie.ts";
import { inferRequestVal } from "https://esm.town/v/andreterron/inferRequestVal?v=2";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { html } from "https://esm.town/v/stevekrouse/html?v=5";
import { zip } from "npm:lodash-es";
import { nanoid } from "npm:nanoid";
type Session = {
id: string;
expiresAt: number;
};
async function createSessionTable(tableName: string) {
await sqlite.execute(`CREATE TABLE ${tableName} (
id TEXT NOT NULL PRIMARY KEY,
expires_at INTEGER NOT NULL,
val_slug STRING NOT NULL
);`);
}
async function createSession(tableName: string, valSlug: string): Promise<Session> {
try {
const expires_at = new Date();
expires_at.setDate(expires_at.getDate() + 7);
const session: Session = { id: nanoid(), expiresAt: expires_at.getTime() };
await sqlite.execute({
sql: `INSERT INTO ${tableName} (id, val_slug, expires_at) VALUES (?, ?, ?)`,
args: [session.id, valSlug, session.expiresAt],
});
return session;
} catch (e) {
if (e.message.includes("no such table")) {
await createSessionTable(tableName);
return createSession(tableName, valSlug);
}
throw e;
}
}
async function getSession(tableName: string, sessionID: string, valSlug: string): Promise<Session> {
try {
const { rows, columns } = await sqlite.execute({
sql: `SELECT * FROM ${tableName} WHERE id = ? AND val_slug = ?`,
args: [sessionID, valSlug],
});
if (rows.length == 0) {
return null;
}
return Object.fromEntries(zip(columns, rows.at(0))) as Session;
} catch (e) {
if (e.message.includes("no such table")) {
return null;
}
throw e;
}
}
async function fetchUser(token: string): Promise<{ id: string }> {
const resp = await fetch("https://api.val.town/v1/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (resp.status !== 200) {
throw new Error("Could not fetch user");
}
return resp.json();
}
async function verifyApiToken(token: string) {
try {
const [currentUser, requestUser] = await Promise.all([fetchUser(Deno.env.get("valtown")), fetchUser(token)]);
return currentUser.id == requestUser.id;
} catch (_) {
return false;
}
}
const loginPage = (handle) =>
`<html>
<head>
<link rel="icon" href="https://fav.farm/🔒" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
</head>
<body style="display: flex; justify-content: center; align-items: center;">
<article>
<p>This val website is <a href="https://www.val.town/v/pomdtr/password_auth">protected by a password</a>.</p>
<p>If you are <a href="https://val.town/u/${handle}">@${handle}</a>, you can access it using an <a href="https://www.val.town/settings/api">API token</a>.</p>
<p>If not, you'll need to contact the author for access.</p>
<footer>
<form method="POST" style="margin-block-end: 0em;">
<fieldset role="group" style="margin-bottom: 0em;">
<input id="password" placeholder="Password" name="password" type="password" />
<input type="submit" value="Sign In"/>

Password Auth Middleware

Protect your vals behind a password. Use session cookies to persist authentication.

6ed0648ae8813e958dbe79468572cb52f578239c0fae55857a13660beebdc5fd.png

Demo

See @pomdtr/password_auth_test

Usage

If you want to use an api token to authenticate:

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); });

Or if you prefer to use a string:

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); }, { password: Deno.env.get("MY_PASSWORD") });

Or if you want to share your val with someone without sharing your main password, you can set multiple ones

Create valimport { passwordAuth } from "https://esm.town/v/pomdtr/password_auth"; export default passwordAuth(() => { return new Response("OK"); }, { password: [Deno.env.get("MY_PASSWORD"), Deno.env.get("STEVE_PASSWORD")] });

Note that authenticating using your api token is always an option.

TODO

  • allow to authenticate using a val town token
  • add a way to send an email to ask a password from the val owner
  • automatically extend the session
  • automatically remove expired sessions

FAQ

How to sign out ?

Navigate to <your-site>/signout.

Readme
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 { deleteCookie, getCookies, setCookie } from "https://deno.land/std/http/cookie.ts";
import { inferRequestVal } from "https://esm.town/v/andreterron/inferRequestVal?v=2";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { html } from "https://esm.town/v/stevekrouse/html?v=5";
import { zip } from "npm:lodash-es";
import { nanoid } from "npm:nanoid";
type Session = {
id: string;
expiresAt: number;
};
async function createSessionTable(tableName: string) {
await sqlite.execute(`CREATE TABLE ${tableName} (
id TEXT NOT NULL PRIMARY KEY,
expires_at INTEGER NOT NULL,
val_slug STRING NOT NULL
);`);
}
async function createSession(tableName: string, valSlug: string): Promise<Session> {
try {
const expires_at = new Date();
expires_at.setDate(expires_at.getDate() + 7);
const session: Session = { id: nanoid(), expiresAt: expires_at.getTime() };
await sqlite.execute({
sql: `INSERT INTO ${tableName} (id, val_slug, expires_at) VALUES (?, ?, ?)`,
args: [session.id, valSlug, session.expiresAt],
});
return session;
} catch (e) {
if (e.message.includes("no such table")) {
await createSessionTable(tableName);
return createSession(tableName, valSlug);
}
throw e;
}
}
async function getSession(tableName: string, sessionID: string, valSlug: string): Promise<Session> {
try {
const { rows, columns } = await sqlite.execute({
sql: `SELECT * FROM ${tableName} WHERE id = ? AND val_slug = ?`,
args: [sessionID, valSlug],
});
if (rows.length == 0) {
return null;
}
return Object.fromEntries(zip(columns, rows.at(0))) as Session;
} catch (e) {
if (e.message.includes("no such table")) {
return null;
}
throw e;
}
}
async function fetchUser(token: string): Promise<{ id: string }> {
const resp = await fetch("https://api.val.town/v1/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (resp.status !== 200) {
throw new Error("Could not fetch user");
}
return resp.json();
}
async function verifyApiToken(token: string) {
try {
const [currentUser, requestUser] = await Promise.all([fetchUser(Deno.env.get("valtown")), fetchUser(token)]);
return currentUser.id == requestUser.id;
} catch (_) {
return false;
}
}
const loginPage = (handle) =>
`<html>
<head>
<link rel="icon" href="https://fav.farm/🔒" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
</head>
<body style="display: flex; justify-content: center; align-items: center;">
<article>
<p>This val is <a href="https://www.val.town/v/pomdtr/password_auth">protected by a password</a>.</p>
<p>To access it, you'll need to get one from <a href="https://val.town/u/${handle}">@${handle}</a>.</p>
<footer>
<form method="POST" style="margin-block-end: 0em;">
<fieldset role="group" style="margin-bottom: 0em;">
<input id="password" placeholder="Password" name="password" type="password" />
<input type="submit" value="Sign In"/>
</fieldset>

Lucia Adapter for val.town

Usage

Create valimport { ValTownAdapter } from "https://esm.town/v/pomdtr/lucia_adapter"; import { Lucia } from "npm:lucia@3.0.1"; const adapter = new ValTownAdapter({ user: "user", session: "session", }); const lucia = new Lucia(adapter)
Readme
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
import { SQLiteAdapter } from "https://esm.town/v/pomdtr/lucia_adapter_base";
import type { Controller, TableNames } from "https://esm.town/v/pomdtr/lucia_adapter_base";
import { sqlite } from "https://esm.town/v/std/sqlite";
import { zip } from "npm:lodash-es";
export class ValTownAdapter extends SQLiteAdapter {
constructor(tableNames: TableNames) {
super(new ValtownController(), tableNames);
}
}
class ValtownController implements Controller {
constructor() {}
public async get<T>(sql: string, args: any[]): Promise<T | null> {
const result = await sqlite.execute({
sql,
args,
});
return Object.fromEntries(zip(result.columns, result.rows.at(0))) as T;
}
public async getAll<T>(sql: string, args: any[]): Promise<T[]> {
const result = await sqlite.execute({
sql,
args,
});
return result.rows.map(row => Object.fromEntries(zip(result.columns, row))) as T[];
}
public async execute(sql: string, args: any[]): Promise<void> {
await sqlite.execute({
sql,
args,
});
}
}
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
import { bulk, sql } from "https://esm.town/v/pomdtr/sql";
import { sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { zip } from "npm:lodash-es";
export type HistoryEntry = {
val_slug: string;
test_name: string;
status: string;
last_run_at: string;
};
export async function createHistory() {
const resp = await sqlite.execute(`CREATE TABLE test_history (
val_slug TEXT,
test_name TEXT,
status TEXT,
last_run_at DATE,
PRIMARY KEY (val_slug, test_name)
);`);
}
export async function readHistory(): Promise<HistoryEntry[]> {
const { columns, rows } = await sqlite.execute("SELECT * from test_history");
const history = rows.map(row =>
Object.fromEntries(zip(
columns,
row,
))
);
return history as HistoryEntry[];
}
export async function writeHistory(entries: HistoryEntry[]) {
const resp = await sqlite.execute(
sql`INSERT OR REPLACE INTO test_history (val_slug, test_name, status, last_run_at) VALUES ${
bulk(entries.map(entry => [
entry.val_slug,
entry.test_name,
entry.status,
entry.last_run_at,
]))
}`,
);
}

SSR React Mini & SQLite Todo App

This Todo App is server rendered and client-hydrated React. This architecture is a lightweight alternative to NextJS, RemixJS, or other React metaframeworks with no compile or build step. The data is saved server-side in Val Town SQLite.

demo

SSR React Mini Framework

This "framework" is currently 44 lines of code, so it's obviously not a true replacement for NextJS or Remix.

The trick is client-side importing the React component that you're server rendering. Val Town is uniquely suited for this trick because it both runs your code server-side and exposes vals as modules importable by the browser.

The tricky part is making sure that server-only code doesn't run on the client and vice-versa. For example, because this val colocates the server-side loader and action with the React component we have to be careful to do all server-only imports (ie sqlite) dynamically inside the loader and action, so they only run server-side.

Readme
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 https://esm.sh/react */
import { zip } from "https://esm.sh/lodash-es";
import { useEffect, useState } from "https://esm.sh/react@18.2.0";
import codeOnValTown from "https://esm.town/v/andreterron/codeOnValTown?v=46";
import { Form, hydrate } from "https://esm.town/v/stevekrouse/ssr_react_mini?v=75";
export async function loader(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const [, { columns, rows }] = await sqlite.batch([
`CREATE TABLE IF NOT EXISTS todos2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
text TEXT NOT NULL
)`,
`select * from todos2`,
]);
const initialTodos = rows.map(row => Object.fromEntries(zip(columns, row)));
return { initialTodos, initialLogs: [`Server rendered`] };
}
export async function action(req: Request) {
const { sqlite } = await import("https://esm.town/v/std/sqlite?v=4");
const formData = await req.formData();
if (req.method === "POST") {
const text = formData.get("text") as string;
const [, { rows }] = await sqlite.batch([{
sql: `insert into todos2(text) values (?)`,
args: [text],
}, "select last_insert_rowid()"]);
return Response.json({ id: rows[0][0], text });
}
else if (req.method === "PUT") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
const { rows } = await sqlite.execute({ sql: `select completed_at from todos2 where id = ?`, args: [id] });
const completed_at = rows[0][0];
const new_completed_at = completed_at ? null : new Date() as any;
await sqlite.execute({ sql: `update todos2 set completed_at = ? where id = ?`, args: [new_completed_at, id] });
}
else if (req.method === "DELETE") {
const id = formData.get("id") as string;
// TODO handle no id (clicked too fast)
await sqlite.execute({ sql: `delete from todos2 where id = ?`, args: [id] });
}
return Response.json("OK");
}
export function Component({ initialTodos, initialLogs }) {
const [todos, setTodos] = useState(initialTodos);
const [logs, setLogs] = useState(initialLogs);
const [newTodo, setNewTodo] = useState("");
const addLog = log => setLogs([...logs, log]);
useEffect(() => addLog(`Client rendered`), []);
function addTodo() {
setTodos([...todos, { text: newTodo }]);
setNewTodo("");
return async (resp: Response) => {
const { text, id } = await resp.json();
setTodos([...todos, { text, id }]); // ok to add again because todos is stale
addLog(`Got ${resp.ok ? "OK" : "Error"} from server for adding todo`);
};
}
function toggleTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.map(t => t.id === id ? { ...t, completed_at: t.completed_at ? null : Date.now() } : t));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for toggling todo`);
}
function deleteTodo(e) {
const formData = new FormData(e.target);
const id = parseInt(formData.get("id") as string);
setTodos(todos.filter(t => t.id !== id));
return (resp: Response) => addLog(`Got ${resp.ok ? "OK" : "Error"} from server for deleting todo`);
}
return (
<html>
<head>
<title>Todo List</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com" />
</head>
<body className="max-w-md mx-auto p-10">
<h2 className="text-xl font-bold pb-2">Todo list</h2>
<ol className="pl-2 max-w-64">
{todos.map((todo) => (
<li key={todo.id} className={`flex justify-between ${todo.completed_at ? "line-through" : ""}`}>
<div className="flex">
<Form action="put" className="mr-2" onSubmit={toggleTodo}>
<input name="id" value={todo.id} type="hidden" />
<button>{todo.completed_at ? "✅" : "o"}</button>
</Form>
{todo.text}
</div>
<Form action="delete" onSubmit={deleteTodo}>
<input name="id" value={todo.id} type="hidden" />