this repo has no description
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial working version

+341
+2
.gitignore
··· 1 + .vtignore 2 + .vt
+8
README.md
··· 1 + # ProtoRuns 2 + 3 + Store your runs using ATProto! Log in with your bluesky handle (sorry) and start 4 + saving and retrieving your runs. 5 + 6 + Press the Remix button to start storing your own records on ATProto! 7 + 8 + Demo available at:[ https://proto-runs.val.run/](https://proto-runs.val.run/)
+28
backend/index.ts
··· 1 + import { serveFile } from "https://esm.town/v/std/utils@85-main/index.ts"; 2 + import { Hono } from "npm:hono"; 3 + import { getClientMetadata } from "../shared/client_metadata.ts"; 4 + 5 + const app = new Hono(); 6 + 7 + // Serve index.html at the root / 8 + app.get("/", (_c) => { 9 + return serveFile("/frontend/index.html", import.meta.url); 10 + }); 11 + 12 + // Serve all /frontend files 13 + app.get("/frontend/**/*", (c) => serveFile(c.req.path, import.meta.url)); 14 + app.get("/shared/**/*", (c) => serveFile(c.req.path, import.meta.url)); 15 + app.get("/public/*", (c) => serveFile(c.req.path, import.meta.url)); 16 + 17 + app.get( 18 + "/client_metadata.json", 19 + (c) => c.json(getClientMetadata(`https://${new URL(c.req.url).hostname}`)), 20 + ); 21 + 22 + app.onError((err, _c) => { 23 + throw err; 24 + }); 25 + 26 + // HTTP vals expect an exported "fetch handler" 27 + // This is how you "run the server" in Val Town with Hono 28 + export default app.fetch;
+37
deno.json
··· 1 + { 2 + "$schema": "https://raw.githubusercontent.com/denoland/deno/348900b8b79f4a434cab4c74b3bc8d4d2fa8ee74/cli/schemas/config-file.v1.json", 3 + "lock": false, 4 + "compilerOptions": { 5 + "noImplicitAny": false, 6 + "strict": false, 7 + "types": [ 8 + "https://www.val.town/types/valtown.d.ts" 9 + ], 10 + "lib": [ 11 + "dom", 12 + "dom.iterable", 13 + "dom.asynciterable", 14 + "deno.ns", 15 + "deno.unstable" 16 + ] 17 + }, 18 + "lint": { 19 + "files": { 20 + "include": [ 21 + "deno:/https/esm.town/**/*" 22 + ] 23 + }, 24 + "rules": { 25 + "exclude": [ 26 + "no-explicit-any" 27 + ] 28 + } 29 + }, 30 + "node_modules_dir": false, 31 + "experimental": { 32 + "unstable-node-globals": true, 33 + "unstable-temporal": true, 34 + "unstable-worker-options": true, 35 + "unstable-sloppy-imports": true 36 + } 37 + }
+57
frontend/components/App.tsx
··· 1 + /** @jsxImportSource https://esm.sh/react@19.1.0 */ 2 + import { useContext, useState } from "https://esm.sh/react@19.1.0"; 3 + import { ATProtoContext } from "../context.ts"; 4 + import { RunForm } from "./RunForm.tsx"; 5 + import { SignIn } from "./SignIn.tsx"; 6 + import { AtUri } from "https://esm.sh/@atproto/api@0.15.5" 7 + 8 + type RunsResponse = { 9 + records: { 10 + uri: string; 11 + value: { 12 + date_iso: string; 13 + created_at: string; 14 + distance_meters: number; 15 + duration_seconds: number; 16 + note: string; 17 + } 18 + }[] 19 + } 20 + 21 + export function App() { 22 + const { session, agent } = useContext(ATProtoContext); 23 + const [runsResponse, setRunsResponse] = useState<RunsResponse>({ records: [] }); 24 + 25 + const fetchRuns = () => agent.com.atproto.repo 26 + .listRecords({ collection: "me.wilb.test.run", repo: session.did }) 27 + .then(res => setRunsResponse(res.data as unknown as RunsResponse)) 28 + 29 + const deleteRun = (rkey: string) => agent.com.atproto.repo 30 + .deleteRecord({ collection: "me.wilb.test.run", repo: session.did, rkey }) 31 + .then(() => fetchRuns()) 32 + 33 + return ( 34 + <> 35 + <h1>ProtoRuns: your runs, on ATProto</h1> 36 + <p><a target="_blank" href="https://www.val.town/x/wilhelm/ProtoRuns/code/README.md">[src]</a></p> 37 + {session && <RunForm />} 38 + <SignIn /> 39 + {session && <button 40 + type="button" 41 + onClick={fetchRuns}> 42 + Get my runs 43 + </button>} 44 + {runsResponse?.records.map(({ uri, value: { note, date_iso, distance_meters, duration_seconds } }) => ( 45 + <div> 46 + <p>distance (kilometers): {distance_meters / 1000}</p> 47 + <p>duration (minutes): {duration_seconds / 60}</p> 48 + <p>date: {date_iso}</p> 49 + <p>note: {note}</p> 50 + <button type="button" onClick={() => deleteRun(new AtUri(uri).rkey)}> 51 + delete 52 + </button> 53 + </div> 54 + ))} 55 + </> 56 + ); 57 + }
+49
frontend/components/RunForm.tsx
··· 1 + /** @jsxImportSource https://esm.sh/react@19.1.0 */ 2 + import { useActionState, useContext } from "https://esm.sh/react@19.1.0"; 3 + import { runSchemaMap } from "../../shared/run_lexicon.ts"; 4 + import { ATProtoContext } from "../context.ts"; 5 + 6 + export function RunForm() { 7 + const { session, agent } = useContext(ATProtoContext); 8 + const [error, submitRun, isPending] = useActionState( 9 + async (_: unknown, formData: FormData) => { 10 + const values = { 11 + distance_meters: Number(formData.get("distance_kilometers")) * 1000, 12 + date_iso: formData.get("date_iso"), 13 + note: formData.get("note"), 14 + duration_seconds: Number(formData.get("duration_minutes")) * 60, 15 + created_at: new Date().toISOString(), 16 + } 17 + 18 + const parsed = runSchemaMap.defs.main.record.safeParse(values) 19 + 20 + if (parsed.success) { 21 + console.log({ data: parsed.data }) 22 + await agent.com.atproto.repo.createRecord({ 23 + repo: session.did, 24 + collection: "me.wilb.test.run", 25 + record: { 26 + $type: "me.wilb.test.run", 27 + ...values, 28 + } 29 + }) 30 + } else { 31 + console.log({ error: parsed.error }) 32 + return parsed.error 33 + } 34 + }, null 35 + ) 36 + return ( 37 + <> 38 + <form action={submitRun}> 39 + <input required name="distance_kilometers" type="number" placeholder="distance (in kilometers)" /> 40 + <input required name="date_iso" type="date" placeholder="date" /> 41 + <input required name="duration_minutes" type="number" placeholder="duration (in minutes)" /> 42 + <input name="note" type="text" placeholder="note" /> 43 + <input type="submit" value="Submit run" /> 44 + </form> 45 + {isPending && <p>submitting...</p>} 46 + {error && <pre>{error}</pre>} 47 + </> 48 + ) 49 + }
+37
frontend/components/SignIn.tsx
··· 1 + /** @jsxImportSource https://esm.sh/react@19.1.0 */ 2 + import { useActionState, useContext } from "https://esm.sh/react@19.1.0"; 3 + import { ATProtoContext } from "../context.ts"; 4 + 5 + export function SignIn() { 6 + const { client, session } = useContext(ATProtoContext); 7 + const [signInError, signIn, isSigningIn] = useActionState( 8 + async (_: unknown, formData: FormData) => { 9 + try { 10 + let handle = String(formData.get("handle")); 11 + if (handle[0] === "@") { 12 + handle = handle.slice(1); 13 + } 14 + await client.signIn(handle); 15 + } 16 + catch (e) { 17 + return e; 18 + } 19 + }, 20 + null, 21 + ); 22 + 23 + return ( 24 + <> 25 + {session 26 + ? <button type="button" onClick={() => session.signOut().then(() => window.location.reload())}>Sign out</button> 27 + : ( 28 + <form action={signIn}> 29 + <input required type="string" name="handle" placeholder="bluesky handle" /> 30 + <button type="submit">Sign in</button> 31 + </form> 32 + )} 33 + {isSigningIn && <p>Signing in...</p>} 34 + {signInError && <pre>{signInError}</pre>} 35 + </> 36 + ); 37 + }
+16
frontend/context.ts
··· 1 + import { createContext } from "https://esm.sh/react@19.1.0"; 2 + import { 3 + BrowserOAuthClient, 4 + OAuthSession, 5 + } from "https://esm.sh/@atproto/oauth-client-browser@0.3.15"; 6 + import { Agent } from "https://esm.sh/@atproto/api@0.15.5"; 7 + 8 + export const ATProtoContext = createContext<{ 9 + session?: OAuthSession; 10 + agent?: Agent; 11 + client: BrowserOAuthClient; 12 + }>({ 13 + session: undefined, 14 + agent: undefined, 15 + client: undefined, 16 + });
+17
frontend/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>ProtoRuns</title> 7 + <link rel="stylesheet" href="/public/style.css"> 8 + <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>"> 9 + </head> 10 + <body> 11 + <main> 12 + <div id="root"></div> 13 + </main> 14 + <script src="https://esm.town/v/std/catch"></script> 15 + <script src="/frontend/index.tsx" type="module"></script> 16 + </body> 17 + </html>
+39
frontend/index.tsx
··· 1 + /** @jsxImportSource https://esm.sh/react@19.1.0 */ 2 + import { createRoot } from "https://esm.sh/react-dom@19.1.0/client"; 3 + import { App } from "./components/App.tsx"; 4 + import { BrowserOAuthClient, OAuthSession } from "https://esm.sh/@atproto/oauth-client-browser@0.3.15" 5 + import { Agent } from "https://esm.sh/@atproto/api@0.15.5" 6 + import { getClientMetadata } from "../shared/client_metadata.ts"; 7 + import { ATProtoContext } from "./context.ts"; 8 + 9 + const client = new BrowserOAuthClient({ 10 + clientMetadata: getClientMetadata(window.location.origin), 11 + handleResolver: "https://bsky.social/", 12 + }) 13 + 14 + const result: undefined | { session: OAuthSession; state?: string } = 15 + await client.init() 16 + 17 + if (typeof result !== "undefined") { 18 + const { session, state } = result ?? {} 19 + if (state != null) { 20 + console.log( 21 + `${session.sub} was successfully authenticated (state: ${state})`, 22 + ) 23 + } else { 24 + console.log(`${session.sub} was restored (last active session)`) 25 + } 26 + } 27 + 28 + const agent = result?.session ? new Agent(result.session) : undefined; 29 + 30 + const root = document.getElementById("root"); 31 + if (!root) { 32 + throw new Error("No root element found"); 33 + } 34 + 35 + createRoot(root).render( 36 + <ATProtoContext value={{ agent, session: result?.session, client }}> 37 + <App /> 38 + </ATProtoContext> 39 + );
+13
public/style.css
··· 1 + html { 2 + margin: 0; 3 + padding: 0; 4 + } 5 + 6 + body, form { 7 + max-width: 400px; 8 + margin: auto; 9 + display: flex; 10 + flex-direction: column; 11 + gap: 2rem; 12 + margin-bottom: 2rem; 13 + }
+14
shared/client_metadata.ts
··· 1 + import type { OAuthClientMetadataInput } from "https://esm.sh/@atproto/oauth-types@0.2.6/dist/index.d.ts"; 2 + 3 + export const getClientMetadata = (baseUrl: string) => ({ 4 + client_id: baseUrl + "/client_metadata.json", 5 + client_name: "Run recorder", 6 + client_uri: baseUrl, 7 + redirect_uris: [baseUrl], 8 + application_type: "web", 9 + dpop_bound_access_tokens: true, 10 + grant_types: ["authorization_code", "refresh_token"], 11 + response_types: ["code"], 12 + scope: "atproto transition:generic", 13 + token_endpoint_auth_method: "none", 14 + } satisfies OAuthClientMetadataInput);
+24
shared/run_lexicon.ts
··· 1 + import { lexiconToZod } from "https://esm.sh/lexicon-to-zod@1.0.2"; 2 + 3 + const lexicon = { 4 + "lexicon": 1, 5 + "id": "me.wilb.test.run", 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "record": { 10 + "type": "object", 11 + "required": ["date", "distance"], 12 + "properties": { 13 + "distance_meters": { "type": "integer" }, 14 + "date_iso": { "type": "string", "format": "datetime" }, 15 + "duration_seconds": { "type": "number" }, 16 + "note": { "type": "string" }, 17 + "createdAt": { "type": "string", "format": "datetime" }, 18 + }, 19 + }, 20 + }, 21 + }, 22 + }; 23 + 24 + export const runSchemaMap = lexiconToZod(lexicon);