Blob Admin

This is a lightweight Blob Admin interface to view and debug your Blob data.

b7321ca2cd80899250589b9aa08bc3cae9c7cea276282561194e7fc537259b46.png

Use this button to install the val:

It uses basic authentication with your Val Town API Token as the password (leave the username field blank).

TODO

  • /new - render a page to write a new blob key and value
  • /edit/:blob - render a page to edit a blob (prefilled with the existing content)
  • /delete/:blob - delete a blob and render success
  • add upload/download buttons
  • Use modals for create/upload/edit/view/delete page (htmx ?)
  • handle non-textual blobs properly
  • use codemirror instead of a textarea for editing text blobs
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/hono@4.0.8/jsx **/
import view_route from "https://esm.town/v/pomdtr/blob_admin_blob";
import create_route from "https://esm.town/v/pomdtr/blob_admin_create";
import delete_route from "https://esm.town/v/pomdtr/blob_admin_delete";
import edit_route from "https://esm.town/v/pomdtr/blob_admin_edit";
import upload_route from "https://esm.town/v/pomdtr/blob_admin_upload";
import { passwordAuth } from "https://esm.town/v/pomdtr/password_auth?v=74";
import { blob } from "https://esm.town/v/std/blob?v=11";
import { Hono } from "npm:hono@4.0.8";
import { jsxRenderer } from "npm:hono@4.0.8/jsx-renderer";
const app = new Hono();
app.use(
jsxRenderer(({ children }) => {
return (
<html>
<head>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
/>
<script
type="module"
src="https://cdn.jsdelivr.net/npm/code-mirror-web-component@0.0.8/dist/code-mirror.js"
>
</script>
<title>Blob Admin</title>
</head>
<body>
<main class="container">
{children}
</main>
</body>
</html>
);
}),
);
app.get("/", async (c) => {
let blobs = await blob.list();
return c.render(
<div class="overflow-auto">
<h1>Blob Admin</h1>
<section
style={{
display: "flex",
gap: "0.5em",
}}
>
<a href="/create">New Blob</a>
<a href="/upload">Upload Blob</a>
</section>
<section>
<table>
<thead>
<tr>
<th>Name</th>
<th>Size (kb)</th>
<th>Last Modified</th>
<th
style={{
textAlign: "center",
}}
>
Edit
</th>
<th
style={{
textAlign: "center",
}}
>
Delete
</th>
<th
style={{
textAlign: "center",
}}
>
Download
</th>
</tr>
</thead>
{blobs.map(b => (
<tr>
<td>
<a href={`/view/${encodeURIComponent(b.key)}`}>
{b.key}
</a>
</td>
<td>{b.size / 1000}</td>
<td>{new Date(b.lastModified).toLocaleString()}</td>
<td
style={{
textAlign: "center",
}}
>
<a href={`/edit/${encodeURIComponent(b.key)}`}>✍️</a>

@postpostscript/readmeManager: Edit Val Readmes With Persistent Drafts

edit this readme

image.png

image.png

Todo:

  • Upload images
  • Autosave/save without reloading page
  • Ctrl+S
  • Multiple draft versions
  • Switch to dark codemirror theme which has markdown styling
  • Allow for checking checkboxes in preview
    • View with just the preview
  • Ability to favorite vals on the Home page
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/hono/jsx */
import { fetchPaginatedData } from "https://esm.town/v/nbbaier/fetchPaginatedData?v=49";
import { api } from "https://esm.town/v/pomdtr/api?v=12";
import { extractValInfo } from "https://esm.town/v/pomdtr/extractValInfo?v=26";
import { authMiddlewareCookie, type HonoEnv } from "https://esm.town/v/postpostscript/authMiddleware";
import { html } from "https://esm.town/v/postpostscript/html";
import { updateValReadme } from "https://esm.town/v/postpostscript/updateValReadme";
import { API_URL } from "https://esm.town/v/std/API_URL?v=5";
import { blob } from "https://esm.town/v/std/blob?v=11";
import { type Context, Hono } from "npm:hono";
const app = new Hono<HonoEnv>();
app.use(
"*",
authMiddlewareCookie({
optional: true,
}),
);
export const DRAFT_PREFIX = "readmeManager:draft";
const styles = `
img {
max-width: 100%;
}
`;
app.all("/:author/:name", async (c) => {
const { pathname } = new URL(c.req.url);
const author = c.req.param("author");
const name = c.req.param("name");
let { id, readme } = await api(`/v1/alias/${author}/${name}`);
const blobID = `${DRAFT_PREFIX}:${id}`;
let draft;
if (c.get("auth")) {
if (c.req.method === "POST") {
const formData = await c.req.formData();
const submitType = formData.get("submit");
if (formData.get("readme")) {
if (submitType === "publish") {
await updateValReadme(id, formData.get("readme"));
return c.redirect(`/${author}/${name}`);
} else {
await blob.setJSON(blobID, formData.get("readme"));
return c.redirect(`/${author}/${name}?draft=1`);
}
}
} else {
draft = await blob.getJSON(blobID);
}
if (c.req.query("draft") && draft) {
readme = draft;
}
}
return c.html(
<html data-bs-theme="dark">
<head>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
crossorigin="anonymous"
/>
<link
href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.4.0/github-markdown.min.css"
rel="stylesheet"
/>
<script
type="module"
src="https://cdn.jsdelivr.net/npm/code-mirror-web-component@0.0.16/dist/code-mirror.js"
>
</script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script
type="module"
dangerouslySetInnerHTML={{
__html: html`
import debounce from 'https://esm.sh/debounce';
async function render(text) {
window.result.innerHTML = await marked.parse(text);
document.getElementById('readme-input').value = text
}
document.addEventListener("DOMContentLoaded", () => {
const $editor = document.createElement('code-mirror')
$editor.setAttribute('id', 'editor')
$editor.setAttribute('theme', 'dracula')
$editor.setAttribute('language', 'markdown')
$editor.setAttribute('code', decodeURIComponent("${encodeURIComponent(readme ?? "")}"))
$editor.setAttribute('class', 'h-100')
$editor.addEventListener("code-change", (e) => {
render(e.detail.code)
})
render($editor.getAttribute('code'));
document.getElementById('editor-col').appendChild($editor)
})

A simple website using Hono, Twind and HTMX.

Hono is a tiny web server library. Now with JSX!

Twind is a tiny Tailwind replacement

HTMX is a tiny way to add interactivity

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
/** @jsx jsx */
import { FC, jsx } from "https://deno.land/x/hono@v3.11.7/middleware.ts";
import { Hono } from "https://deno.land/x/hono@v3.11.7/mod.ts";
import { blob } from "https://esm.town/v/std/blob";
const app = new Hono();
const TopBar: FC = () => (
<div class="w-full p-4 flex font-bold place-content-between flex-row">
<a href="/">⚙️ Control Panel</a>
<a href="https://www.val.town/v/wilhelm/HTHTMX">&lt;/&gt;</a>
</div>
);
const Layout: FC = ({ children, title = "Control Panel" }) => (
<html lang="en" hidden>
<head>
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚙️</text></svg>"
/>
<title>{title}</title>
<script src="https://unpkg.com/htmx.org@1.9.9"></script>
<script type="module" src="https://cdn.skypack.dev/twind/shim"></script>
</head>
<body class="h-screen flex flex-col bg-gray-600 text-gray-800">
<TopBar />
{children}
</body>
</html>
);
const Toggle = ({ on }) => (
<div id="toggleSection">
<div class="font-bold text-xl flex-col">
{on ? "It's so on" : "You should turn it on"}
</div>
<div class="flex items-center justify-center p-2">
<input
hx-put="/toggle"
hx-swap="outerHTML"
hx-target="#toggleSection"
type="checkbox"
name="toggle"
checked={on}
/>
</div>
</div>
);
const Home: FC = async () => (
<main class="flex-grow flex items-center justify-center">
<Toggle on={(await blob.getJSON("bool"))?.toggle ?? false} />
</main>
);
app.get("/", (c) =>
c.html(
<Layout>
<Home />
</Layout>,
));
app.put("/toggle", async (c) => {
const on = Boolean((await c.req.formData()).get("toggle"));
await blob.setJSON("bool", { toggle: on });
return c.html(<Toggle on={on} />);
});
export default app.fetch;
1
2
3
4
5
6
7
8
9
import { Command } from "https://esm.town/v/pomdtr/cmdk";
import { blob } from "https://esm.town/v/std/blob?v=12";
export const helloWorld: Command = async (ctx) => {
const key = ctx.params.key;
await blob.delete(key);
return { type: "reload" };
};

BTC Price Alert

This val monitors the price of Bitcoin (BTC) and sends an email alert if the price fluctuates significantly. Specifically, it checks the current BTC price against the last recorded price and triggers an email notification if the change exceeds 20%. The email includes the new price, formatted as currency.

Fork this val to get these notifications on your inbox.

Runs every 1 hrs
Fork
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { formatAsCurrency } from "https://esm.town/v/panphora/formatAsCurrency?v=5";
import { blob } from "https://esm.town/v/std/blob?v=11";
import { email } from "https://esm.town/v/std/email?v=9";
import { currency } from "https://esm.town/v/stevekrouse/currency";
export async function btcPriceAlert() {
const lastBtcPrice: number = await blob.getJSON("lastBtcPrice");
let btcPrice = await currency("usd", "btc");
let change = Math.abs(btcPrice - lastBtcPrice);
if (change / lastBtcPrice > .1) {
await blob.setJSON("lastBtcPrice", btcPrice);
let formattedBtcPrice = formatAsCurrency(btcPrice);
await email({
text: formattedBtcPrice,
subject: "BTC PRICE ALERT: " + formattedBtcPrice,
});
}
return btcPrice;
}

Sends me an email if Sweden's requirements for EU Blue Card changes. Checks once an hour.

Runs every 1 hrs
Fork
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
import { blob } from "https://esm.town/v/std/blob?v=12";
import { email } from "https://esm.town/v/std/email?v=12";
const site =
"https://www.migrationsverket.se/English/Private-individuals/Working-in-Sweden/Employed/Special-rules-for-certain-occupations-and-citizens-of-certain-countries/EU-Blue-Card.html";
const dateChangedKey = "work-permit-change-date";
export default async function(interval: Interval) {
const page = await fetch(site).then(res => res.text());
const date = page.match(/Last updated: <time datetime="([^"]+)">/)?.[1].toString();
const lastChange = await blob.get(dateChangedKey).then(res => res?.text()).catch(() => "");
if (!date) {
throw new Error("Couldn't find date");
}
if (!lastChange || date === lastChange) {
return;
}
await blob.set(dateChangedKey, date);
await email({
subject: `EU Blue Card requirements page changed at ${new Date(date).toDateString()}`,
text: `Go check ${site}`,
});
}
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
import { getMainExport } from "https://esm.town/v/easrng/oldstyleUtil?v=1";
import { API_URL } from "https://esm.town/v/std/API_URL?v=5";
import { blob } from "https://esm.town/v/std/blob?v=11";
import { decodeBase64, encodeBase64 } from "jsr:@std/encoding@0.219.1/base64";
type SerializedRequest = {
headers: [string, string][];
method: string;
url: string;
body?: string;
};
type SerializedResponse = {
headers: [string, string][];
status: number;
statusText: string;
body: string;
};
export async function handle(req: Request) {
const id = new URL(req.url).hostname.match(/^websandbox-(play_[^\.]+)/)[1];
const code = await (await blob.get(id)).text();
const serializedRequest: SerializedRequest = {
headers: [],
method: req.method,
url: req.url,
body: (req.method === "GET" || req.method === "HEAD") ? undefined : encodeBase64(await req.arrayBuffer()),
};
req.headers.forEach((value, key) => {
serializedRequest.headers.push([key, value]);
});
const res = await fetch(API_URL + "/v1/eval", {
method: "POST",
body: JSON.stringify({
code: `async (serializedRequest, code) => JSON.stringify(await(await import(${
JSON.stringify(import.meta.url)
})).inner(serializedRequest, code))`,
args: [serializedRequest, code],
}),
});
const serializedResponse: SerializedResponse = await res.json();
return new Response(decodeBase64(serializedResponse.body), {
headers: serializedResponse.headers,
status: serializedResponse.status,
statusText: serializedResponse.statusText,
});
}
export async function inner(serializedRequest: SerializedRequest, code: string): Promise<SerializedResponse> {
try {
const req = new Request(serializedRequest.url, {
method: serializedRequest.method,
headers: serializedRequest.headers,
body: serializedRequest.body ? decodeBase64(serializedRequest.body) : undefined,
});
const blob = new Blob([code], {
type: "text/tsx",
});
const url = URL.createObjectURL(blob);
const handler = getMainExport(await import(url));
URL.revokeObjectURL(url);
const res: unknown = await handler(req);
if (!(res instanceof Response)) {
throw new TypeError(
"return type must be Response, got " + (typeof res === "object" ? Object.toString.call(res) : typeof res),
);
}
const serializedResponse: SerializedResponse = {
status: res.status,
statusText: res.statusText,
headers: [],
body: encodeBase64(await res.arrayBuffer()),
};
res.headers.forEach((value, key) => {
serializedResponse.headers.push([key, value]);
});
return serializedResponse;
} catch (e) {
return {
status: 500,
statusText: "Internal Server Error",
headers: [
["content-type", "text/plain"],
],
body: encodeBase64(e.stack),
};
}
}

Bluesky keyword alerts

Custom notifications for when you, your company, or anything you care about is mentioned on Bluesky.

1. Query

Specify your queries in the queries variable.

Bluesky doesn't support boolean OR yet so we do a separate search for each keyword.

2. Notification

Below I'm sending these mentions to a private channel in our company Discord, but you can customize that to whatever you want, @std/email, Slack, Telegram, whatever.

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
import { blob } from "https://esm.town/v/std/blob";
import { discordWebhook } from "https://esm.town/v/stevekrouse/discordWebhook";
import { fetchJSON } from "https://esm.town/v/stevekrouse/fetchJSON";
import { searchBlueskyPosts } from "https://esm.town/v/stevekrouse/searchBlueskyPosts";
const encounteredIDs_KEY = "bluesky_encounteredIDs";
const queries = ["val town", "val.town"];
export const blueskyAlert = async () => {
let posts = (await Promise.all(queries.map(searchBlueskyPosts))).flat();
// filter for new posts
let encounteredIDs = await blob.getJSON(encounteredIDs_KEY) ?? [];
let newPosts = posts.filter((post) => !encounteredIDs.includes(post.tid));
await blob.setJSON(encounteredIDs_KEY, [
...encounteredIDs,
...newPosts.map((post) => post.tid),
]);
if (newPosts.length === 0) return;
// format
const content = posts.map(
post => `https://bsky.app/profile/${post.user.handle}/post/${post.tid.split("/")[1]}`,
).join(
"\n",
);
// notify
await discordWebhook({
url: Deno.env.get("mentionsDiscord"),
content,
});
return newPosts;
};

Render form and save data

This val provides a web-based interface for collecting email addresses. It features a dual-functionality approach: when accessed via a web browser using a GET request, it serves an HTML form where users can submit their email address. If the script receives a POST request, it implies that the form has been submitted, and it proceeds to handle the incoming data.

Fork this val to customize it and use it on your account.

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
import { blob } from "https://esm.town/v/std/blob?v=11";
import { email } from "https://esm.town/v/std/email?v=9";
import { html } from "https://esm.town/v/stevekrouse/html?v=5";
export const renderFormAndSaveData = async (
const weights = (await blob.getJSON("weights")) ?? [];
req: Request,
): Promise<Response> => {
// A visit from a web browser? Serve a HTML page with a form
if (req.method === "GET") {
return new Response(
render(`<!DOCTYPE html>
<html>
<head>
<title>Weight entry</title>
</head>
<body>
<form action="/" method="post">
<label for="weight">Weight:</label>
<input type="weight" id="weight" name="weight" required>
<br>
<input type="submit" value="Save">
</form>
</body>
</html>`)
}
// Otherwise, someone has submitted a form so let's handle that
const formData = await req.formData();
const weight = formData.get("weight");
weights.push(weight);
await blob.setJSON("weights", weights);
return new Response("done");
};

Usage:

Create valimport blobEditor from "https://esm.town/v/pomdtr/blob_editor" export default blobEditor("article.md")

You can easily protect your val behind @pomdtr/passwordAuth or @pomdtr/emailAuth

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 { extractValInfo } from "https://esm.town/v/pomdtr/extractValInfo";
import { html } from "https://esm.town/v/pomdtr/gfm";
import { blob } from "https://esm.town/v/std/blob?v=11";
import { Hono } from "npm:hono";
import { HTTPException } from "npm:hono/http-exception";
import { jsxRenderer, useRequestContext } from "npm:hono/jsx-renderer";
export function blobEditor(key: string, options?: { title?: string }) {
const router = new Hono();
router.use(jsxRenderer(({ children }) => {
const c = useRequestContext();
const { pathname } = new URL(c.req.url);
return (
<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>
<header class="container">
<nav>
<ul>
<li>
<a href={"/"}>
<strong>{options?.title || "Blob Editor"}</strong>
</a>
</li>
</ul>
{pathname == "/"
? (
<ul>
<li>
<a href={"/edit"} role="button" class="outline">Edit Blob</a>
</li>
</ul>
)
: undefined}
</nav>
</header>
<main class="container">
{children}
</main>
</body>
</html>
);
}));
router.get("/", async (c) => {
if (key.endsWith(".md")) {
const markdown = await readBlob(key);
return c.render(
<article dangerouslySetInnerHTML={{ __html: await html(markdown) }}>
</article>,
);
}
const text = await readBlob(key);
return c.text(text);
});
router.get("/edit", async (c) => {
const text = readBlob(key);
return c.render(
<main class="container">
<form method="POST" action="/">
<article>
<textarea name="content" id="editor" style={{ height: "60vh" }}>{text}</textarea>
<footer>
<input type="submit" value="Save" />
</footer>
</article>
</form>
</main>,
);
});
router.post("/", async (c) => {
const body = await c.req.parseBody();
if (typeof body.content != "string") {
throw new HTTPException(400);
}
await blob.set(key, body.content);
return c.redirect("/");
});
return router.fetch;
}
async function readBlob(key: string) {
const resp = await blob.get(key);
if (resp.status == 200) {
return resp.text();
} else if (resp.status == 404) {
return "";
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
import { blobActions } from "https://esm.town/v/pomdtr/blob_actions";
import { ActionItem, defineCommand } from "https://esm.town/v/pomdtr/cmdk";
import { extractValInfo } from "https://esm.town/v/pomdtr/extractValInfo";
import { blob } from "https://esm.town/v/std/blob?v=12";
const { slug } = extractValInfo(import.meta.url);
export const actions: ActionItem[] = [
{
title: "List Blobs",
type: "push",
push: {
command: slug,
},
},
];
export default defineCommand(async () => {
const blobs = await blob.list();
return {
type: "list",
list: {
items: blobs.map(blob => ({
title: blob.key,
actions: [
...blobActions(blob.key),
{
title: "Create Blob",
type: "push",
push: {
command: "pomdtr/create_blob",
},
},
],
})),
},
};
});
Runs every 1 hrs
Fork
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
import { alias } from "https://esm.town/v/neverstew/alias?v=5";
import { deleteVal } from "https://esm.town/v/neverstew/deleteVal?v=2";
import { blob } from "https://esm.town/v/std/blob?v=11";
import { EXPIRY } from "https://esm.town/v/websandbox/config";
export default async function(interval: Interval) {
for (const b of await blob.list("play_")) {
if ((new Date(b.lastModified).valueOf() + EXPIRY) < Date.now()) {
try {
const resolved = await alias({
username: "websandbox",
valName: b.key,
token: Deno.env.get("valtown"),
});
await deleteVal({
id: resolved.id,
token: Deno.env.get("valtown"),
});
await blob.delete(b.key);
console.log("deleted", b);
} catch (e) {
console.error(e);
}
}
}
}
1
2
3
4
5
6
7
import { blob } from "https://esm.town/v/std/blob";
export const transmitter = async (req: Request) => {
const searchParams = new URL(req.url).searchParams;
const channels = await blob.getJSON("channels");
return Response.json(channels[searchParams.get("channel")]);
};
1
2
3
4
5
6
7
import { blob } from "https://esm.town/v/std/blob?v=11";
export default async function(req: Request): Promise<Response> {
const count = (await blob.getJSON("blobCounter") ?? 0) + 1;
await blob.setJSON("blobCounter", count);
return Response.json(count);
}

Set Blob Storage via fetch Response body

Inspired by Wes Bos's tweet about Bun's elegant Filesystem API.

1
2
3
4
5
6
import { blob } from "https://esm.town/v/std/blob?v=12";
const url = "https://example.com";
const key = "exampleBlob";
await blob.set(key, (await fetch(url)).body);
console.log(await (await blob.get(key)).text());