├── .gitignore
├── bun.lockb
├── public
└── icon.png
├── lib
├── redis.ts
├── upthumb.ts
└── ui.ts
├── README.md
├── package.json
├── tsconfig.json
├── LICENSE
└── api
└── index.tsx
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env
3 | .vercel
4 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/horsefacts/upthumbs/HEAD/bun.lockb
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/horsefacts/upthumbs/HEAD/public/icon.png
--------------------------------------------------------------------------------
/lib/redis.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from "ioredis";
2 |
3 | export const redis = new Redis(
4 | process.env.REDIS_URL ?? "redis://localhost:6379",
5 | );
6 |
7 | export default redis;
8 |
--------------------------------------------------------------------------------
/lib/upthumb.ts:
--------------------------------------------------------------------------------
1 | import redis from "./redis.js";
2 |
3 | export async function upthumb(fid: number, username: string) {
4 | const id = fid.toString();
5 | await redis.zincrby("upthumbs", 1, id);
6 | await redis.hset("usernames", id, username);
7 | }
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 👍 Upthumbs
2 |
3 | A Farcaster [Cast Actions](https://warpcast.notion.site/Frames-Cast-Actions-v1-84d5a85d479a43139ea883f6823d8caa) example app.
4 |
5 |
6 | ```
7 | npm install
8 | npm run dev
9 | ```
10 |
11 | Head to http://localhost:5173/api
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "upthumbs",
3 | "private": true,
4 | "type": "module",
5 | "scripts": {
6 | "build": "frog vercel-build",
7 | "dev": "frog dev",
8 | "deploy": "vercel"
9 | },
10 | "dependencies": {
11 | "@neynar/nodejs-sdk": "^1.14.0",
12 | "frog": "latest",
13 | "hono": "^4",
14 | "ioredis": "^5.3.2"
15 | },
16 | "devDependencies": {
17 | "tsx": "^4.7.1",
18 | "typescript": "^5.3.3",
19 | "vercel": "^32.4.1"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/lib/ui.ts:
--------------------------------------------------------------------------------
1 | import { createSystem } from "frog/ui";
2 |
3 | export const { Box, Heading, Text, VStack, vars } = createSystem({
4 | colors: {
5 | white: "white",
6 | black: "black",
7 | fcPurple: "rgb(138, 99, 210)"
8 | },
9 | fonts: {
10 | default: [
11 | {
12 | name: "Inter",
13 | source: "google",
14 | weight: 400,
15 | },
16 | {
17 | name: "Inter",
18 | source: "google",
19 | weight: 600,
20 | },
21 | ],
22 | },
23 | });
24 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "NodeNext",
5 | "skipLibCheck": true,
6 |
7 | "moduleResolution": "NodeNext",
8 | "resolveJsonModule": true,
9 | "isolatedModules": true,
10 | "jsx": "react-jsx",
11 | "jsxImportSource": "frog/jsx",
12 |
13 | /* Linting */
14 | "strict": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "noFallthroughCasesInSwitch": true,
18 |
19 | "types": ["frog/client"]
20 | },
21 | "include": ["**/*.ts", "**/*.tsx", "**/*.mtsx"]
22 | }
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2024 Merkle Manufactory and others
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining
6 | a copy of this software and associated documentation files (the
7 | "Software"), to deal in the Software without restriction, including
8 | without limitation the rights to use, copy, modify, merge, publish,
9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/api/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Frog } from "frog";
2 | import { devtools } from "frog/dev";
3 | import { serveStatic } from "frog/serve-static";
4 | import { neynar as neynarHub } from "frog/hubs";
5 | import { neynar } from "frog/middlewares";
6 | import { handle } from "frog/vercel";
7 | import { CastParamType, NeynarAPIClient } from "@neynar/nodejs-sdk";
8 | import { upthumb } from "../lib/upthumb.js";
9 | import { Box, Heading, Text, VStack, vars } from "../lib/ui.js";
10 | import redis from "../lib/redis.js";
11 |
12 | const NEYNAR_API_KEY = process.env.NEYNAR_API_KEY ?? "";
13 | const neynarClient = new NeynarAPIClient(NEYNAR_API_KEY);
14 |
15 | const ADD_URL =
16 | "https://warpcast.com/~/add-cast-action?url=https://upthumbs.app/api/upthumb";
17 |
18 | export const app = new Frog({
19 | assetsPath: "/",
20 | basePath: "/api",
21 | ui: { vars },
22 | hub: neynarHub({ apiKey: NEYNAR_API_KEY }),
23 | browserLocation: ADD_URL,
24 | }).use(
25 | neynar({
26 | apiKey: NEYNAR_API_KEY,
27 | features: ["interactor", "cast"],
28 | })
29 | );
30 |
31 | // Cast action GET handler
32 | app.hono.get("/upthumb", async (c) => {
33 | return c.json({
34 | name: "Upthumb",
35 | icon: "thumbsup",
36 | description: "Give casts 'upthumbs' and see them on a leaderboard.",
37 | aboutUrl: "https://github.com/horsefacts/upthumbs",
38 | action: {
39 | type: "post",
40 | },
41 | });
42 | });
43 |
44 | // Cast action POST handler
45 | app.hono.post("/upthumb", async (c) => {
46 | const {
47 | trustedData: { messageBytes },
48 | } = await c.req.json();
49 |
50 | const result = await neynarClient.validateFrameAction(messageBytes);
51 | if (result.valid) {
52 | const cast = await neynarClient.lookUpCastByHashOrWarpcastUrl(
53 | result.action.cast.hash,
54 | CastParamType.Hash
55 | );
56 | const {
57 | cast: {
58 | author: { fid, username },
59 | },
60 | } = cast;
61 | if (result.action.interactor.fid === fid) {
62 | return c.json({ message: "Nice try." }, 400);
63 | }
64 |
65 | await upthumb(fid, username);
66 |
67 | let message = `You upthumbed ${username}`;
68 | if (message.length > 30) {
69 | message = "Upthumbed!";
70 | }
71 |
72 | return c.json({ message, link: "https://warpcast.com/horsefacts.eth/0x09d647a9" });
73 | } else {
74 | return c.json({ message: "Unauthorized" }, 401);
75 | }
76 | });
77 |
78 | // Frame handlers
79 | app.frame("/", (c) => {
80 | return c.res({
81 | image: (
82 |
89 |
90 |
91 | Upthumbs 👍
92 |
93 |
94 |
95 | ),
96 | intents: [
97 | Add Action,
98 | ,
101 | ,
104 | ],
105 | });
106 | });
107 |
108 | app.frame("/leaderboard", async (c) => {
109 | const leaders = await redis.zrevrange("upthumbs", 0, 3, "WITHSCORES");
110 | const [firstFid, firstScore, secondFid, secondScore, thirdFid, thirdScore] =
111 | leaders;
112 |
113 | const firstName = await redis.hget("usernames", firstFid);
114 | const secondName = await redis.hget("usernames", secondFid);
115 | const thirdName = await redis.hget("usernames", thirdFid);
116 |
117 | return c.res({
118 | image: (
119 |
126 |
127 |
128 | Leaderboard
129 |
130 |
131 |
132 | 🥇 {firstName}: {firstScore}
133 |
134 |
135 | 🥈 {secondName}: {secondScore}
136 |
137 |
138 | 🥉 {thirdName}: {thirdScore}
139 |
140 |
141 |
142 |
143 | ),
144 | intents: [⬅️ Back],
145 | });
146 | });
147 |
148 | app.frame("/upthumbs", async (c) => {
149 | const fid = c.var.interactor?.fid ?? 0;
150 | let upthumbs = "0";
151 | try {
152 | upthumbs = (await redis.zscore("upthumbs", fid)) ?? "0";
153 | } catch (e) {}
154 |
155 | return c.res({
156 | image: (
157 |
164 |
165 |
166 | Your Upthumbs:
167 |
168 |
169 | {upthumbs}
170 |
171 |
172 |
173 | ),
174 | intents: [⬅️ Back],
175 | });
176 | });
177 |
178 | // @ts-ignore
179 | const isEdgeFunction = typeof EdgeFunction !== "undefined";
180 | const isProduction = isEdgeFunction || import.meta.env?.MODE !== "development";
181 | devtools(app, isProduction ? { assetsPath: "/.frog" } : { serveStatic });
182 |
183 | export const GET = handle(app);
184 | export const POST = handle(app);
185 |
--------------------------------------------------------------------------------