tangled
alpha
login
or
join now
ciaran.co.za
/
cumulus
0
fork
atom
A Prediction Market on the AT Protocol
0
fork
atom
overview
issues
pulls
pipelines
feat(web): more mpv ui, add buy buttons
Ciaran
1 week ago
7591382a
7610a131
+185
-48
7 changed files
expand all
collapse all
unified
split
bun.lock
package.json
src
web
app.tsx
components
ui
sonner.tsx
index.css
main.tsx
providers
cumulus-provider.tsx
+6
bun.lock
···
27
27
"drizzle-orm": "^0.45.1",
28
28
"elysia": "^1.4.27",
29
29
"lucide-react": "^0.577.0",
30
30
+
"next-themes": "^0.4.6",
30
31
"pg": "^8.19.0",
31
32
"radix-ui": "^1.4.3",
32
33
"recharts": "2.15.4",
34
34
+
"sonner": "^2.0.7",
33
35
"tailwind-merge": "^3.5.0",
34
36
"tailwindcss": "^4.2.1",
35
37
"usehooks-ts": "^3.1.1",
···
1132
1134
1133
1135
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
1134
1136
1137
1137
+
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
1138
1138
+
1135
1139
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
1136
1140
1137
1141
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
···
1323
1327
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
1324
1328
1325
1329
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
1330
1330
+
1331
1331
+
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
1326
1332
1327
1333
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
1328
1334
+2
package.json
···
64
64
"drizzle-orm": "^0.45.1",
65
65
"elysia": "^1.4.27",
66
66
"lucide-react": "^0.577.0",
67
67
+
"next-themes": "^0.4.6",
67
68
"pg": "^8.19.0",
68
69
"radix-ui": "^1.4.3",
69
70
"recharts": "2.15.4",
71
71
+
"sonner": "^2.0.7",
70
72
"tailwind-merge": "^3.5.0",
71
73
"tailwindcss": "^4.2.1",
72
74
"usehooks-ts": "^3.1.1"
+63
-12
src/web/app.tsx
···
4
4
import { LineChart, Line, Tooltip } from "recharts";
5
5
import { ChartContainer } from "./components/ui/chart";
6
6
import { noPrice, yesPrice } from "./lib/lmsr";
7
7
+
import { Button } from "./components/ui/button";
8
8
+
import type { Market } from "./providers/cumulus-provider";
9
9
+
import { useState } from "react";
10
10
+
import { createBet } from "@/core";
11
11
+
import { useAuth } from "./providers/useAuth";
12
12
+
import type { ResourceUri } from "@atcute/lexicons";
13
13
+
import { toast } from "sonner";
14
14
+
15
15
+
function parseMarket(market: Market) {
16
16
+
let [yes, no] = [0, 0];
17
17
+
18
18
+
const mappedBets = market.bets
19
19
+
?.sort((a, b) => a.createdAt > b.createdAt ? 1 : 0)
20
20
+
.map(bet => {
21
21
+
bet.position === "yes" ? yes++ : no++;
22
22
+
return { ...bet, yes, no, }
23
23
+
})
24
24
+
25
25
+
const yesprice = yesPrice(yes, no, market.liquidity)
26
26
+
const noprice = noPrice(yes, no, market.liquidity)
27
27
+
const positions = market.bets?.length ?? 0;
28
28
+
const closesAt = formatDistance(new Date(market.closesAt), new Date(), { addSuffix: true })
29
29
+
30
30
+
return {
31
31
+
yes, no, mappedBets, yesprice, noprice, positions, closesAt
32
32
+
}
33
33
+
}
7
34
8
35
export default function App() {
36
36
+
const { profile, client } = useAuth();
9
37
const { markets } = useCumulus();
10
38
39
39
+
const [loading, setLoading] = useState<string | boolean>(false);
40
40
+
11
41
if (markets.isLoading) return <Spinner className='m-auto' />
12
42
13
43
return <div className="grid md:grid-cols-2 gap-2">
14
44
{markets.data?.map(market => {
15
15
-
let [yes, no] = [0, 0];
16
16
-
let mappedBets = market.bets
17
17
-
?.sort((a, b) => a.createdAt > b.createdAt ? 1 : 0)
18
18
-
.map(bet => {
19
19
-
if (bet.position === "yes") yes++;
20
20
-
if (bet.position === "no") no++;
21
21
-
return { ...bet, yes, no, }
22
22
-
})
45
45
+
46
46
+
const { yesprice, noprice, closesAt, mappedBets, positions } = parseMarket(market)
47
47
+
48
48
+
async function handleBuy(position: "yes" | "no") {
49
49
+
setLoading(market.cid)
50
50
+
try {
51
51
+
const res = await createBet({
52
52
+
uri: market.uri as ResourceUri,
53
53
+
cid: market.cid,
54
54
+
}, position, profile.did, client)
55
55
+
if (res.uri) {
56
56
+
toast(`Placed "${position.toUpperCase()}" bet (${res.uri}) at market ${market.uri}`);
57
57
+
toast(<div>
58
58
+
<p>Placed bet: <a href={`https://pdsls.dev/${res.uri}`}>{position.toUpperCase()}</a></p>
59
59
+
<p>At market: <a href={`https://pdsls.dev/${market.uri}`}>{market.rkey}</a></p>
60
60
+
</div>)
61
61
+
}
62
62
+
} catch (e) {
63
63
+
toast(e as any)
64
64
+
}
65
65
+
setLoading(false);
66
66
+
}
67
67
+
23
68
return <div key={market.cid} className="relative uppercase bg-radial-[at_80%_200%] from-coral-500 via-coral-50">
69
69
+
24
70
<div className="absolute inset-0 p-2">
25
71
<h2 className="text-xl font-bold flex gap-1 items-center">{market.question}</h2>
26
26
-
<p>Closes: {formatDistance(new Date(market.closesAt), new Date(), { addSuffix: true })}</p>
27
27
-
<p>Positions: {market.bets?.length}</p>
28
28
-
<p>Yes Price: {yesPrice(yes, no, market.liquidity)}</p>
29
29
-
<p>No Price: {noPrice(yes, no, market.liquidity)}</p>
72
72
+
<p>Closes: {closesAt}</p>
73
73
+
<p>Positions: {positions}</p>
30
74
</div>
75
75
+
31
76
<ChartContainer
32
77
config={{ yes: { label: "Yes" }, no: { label: "No" } }}>
33
78
<LineChart data={mappedBets}>
···
36
81
<Line dataKey="no" stroke="var(--color-coral-600)" />
37
82
</LineChart>
38
83
</ChartContainer>
84
84
+
85
85
+
<div className="absolute bottom-0 right-0 p-2 flex gap-2">
86
86
+
<Button onClick={() => handleBuy("yes")} disabled={loading === market.cid}>YES {yesprice}</Button>
87
87
+
<Button onClick={() => handleBuy("no")} variant="secondary" disabled={loading === market.cid}>NO {noprice}</Button>
88
88
+
</div>
89
89
+
39
90
</div>
40
91
})}
41
92
</div>
+38
src/web/components/ui/sonner.tsx
···
1
1
+
import {
2
2
+
CircleCheckIcon,
3
3
+
InfoIcon,
4
4
+
Loader2Icon,
5
5
+
OctagonXIcon,
6
6
+
TriangleAlertIcon,
7
7
+
} from "lucide-react"
8
8
+
import { useTheme } from "next-themes"
9
9
+
import { Toaster as Sonner, type ToasterProps } from "sonner"
10
10
+
11
11
+
const Toaster = ({ ...props }: ToasterProps) => {
12
12
+
const { theme = "system" } = useTheme()
13
13
+
14
14
+
return (
15
15
+
<Sonner
16
16
+
theme={theme as ToasterProps["theme"]}
17
17
+
className="toaster group"
18
18
+
icons={{
19
19
+
success: <CircleCheckIcon className="size-4" />,
20
20
+
info: <InfoIcon className="size-4" />,
21
21
+
warning: <TriangleAlertIcon className="size-4" />,
22
22
+
error: <OctagonXIcon className="size-4" />,
23
23
+
loading: <Loader2Icon className="size-4 animate-spin" />,
24
24
+
}}
25
25
+
style={
26
26
+
{
27
27
+
"--normal-bg": "var(--popover)",
28
28
+
"--normal-text": "var(--popover-foreground)",
29
29
+
"--normal-border": "var(--border)",
30
30
+
"--border-radius": "var(--radius)",
31
31
+
} as React.CSSProperties
32
32
+
}
33
33
+
{...props}
34
34
+
/>
35
35
+
)
36
36
+
}
37
37
+
38
38
+
export { Toaster }
+72
-35
src/web/index.css
···
82
82
83
83
:root {
84
84
--radius: 0.625rem;
85
85
-
--background: oklch(0.97 0.01 240); /* shell-50 #F5F7F9 */
86
86
-
--foreground: oklch(0.17 0.01 240); /* shell-900 #101C26 */
87
87
-
--card: oklch(0.95 0.01 240); /* shell-100 #EBEFF2 */
85
85
+
--background: oklch(0.97 0.01 240);
86
86
+
/* shell-50 #F5F7F9 */
87
87
+
--foreground: oklch(0.17 0.01 240);
88
88
+
/* shell-900 #101C26 */
89
89
+
--card: oklch(0.95 0.01 240);
90
90
+
/* shell-100 #EBEFF2 */
88
91
--card-foreground: oklch(0.17 0.01 240);
89
89
-
--popover: oklch(0.95 0.01 240); /* shell-100 */
92
92
+
--popover: oklch(0.95 0.01 240);
93
93
+
/* shell-100 */
90
94
--popover-foreground: oklch(0.17 0.01 240);
91
91
-
--primary: oklch(0.65 0.18 350); /* coral-500 #F67280 */
95
95
+
--primary: oklch(0.65 0.18 350);
96
96
+
/* coral-500 #F67280 */
92
97
--primary-foreground: oklch(0.97 0.01 240);
93
93
-
--secondary: oklch(0.96 0.02 350); /* lipstick-100 #F9F0F3 */
94
94
-
--secondary-foreground: oklch(0.42 0.12 350); /* lipstick-700 #73414F */
95
95
-
--muted: oklch(0.95 0.01 240); /* shell-100 */
96
96
-
--muted-foreground: oklch(0.45 0.12 230); /* shell-500 #355C7D */
97
97
-
--accent: oklch(0.92 0.04 350); /* lipstick-200 #EFDAE0 */
98
98
-
--accent-foreground: oklch(0.25 0.06 350); /* lipstick-800 #56313B */
99
99
-
--destructive: oklch(0.55 0.15 355); /* coral-600 #DD6773 */
100
100
-
--border: oklch(0.88 0.03 230); /* shell-200 #CDD6DF */
98
98
+
--secondary: oklch(0.96 0.02 350);
99
99
+
/* lipstick-100 #F9F0F3 */
100
100
+
--secondary-foreground: oklch(0.42 0.12 350);
101
101
+
/* lipstick-700 #73414F */
102
102
+
--muted: oklch(0.95 0.01 240);
103
103
+
/* shell-100 */
104
104
+
--muted-foreground: oklch(0.45 0.12 230);
105
105
+
/* shell-500 #355C7D */
106
106
+
--accent: oklch(0.92 0.04 350);
107
107
+
/* lipstick-200 #EFDAE0 */
108
108
+
--accent-foreground: oklch(0.25 0.06 350);
109
109
+
/* lipstick-800 #56313B */
110
110
+
--destructive: oklch(0.55 0.15 355);
111
111
+
/* coral-600 #DD6773 */
112
112
+
--border: oklch(0.88 0.03 230);
113
113
+
/* shell-200 #CDD6DF */
101
114
--input: oklch(0.88 0.03 230);
102
102
-
--ring: oklch(0.72 0.18 350); /* coral-400 #F99CA6 */
103
103
-
--chart-1: oklch(0.65 0.18 350); /* coral */
104
104
-
--chart-2: oklch(0.55 0.15 350); /* lipstick */
105
105
-
--chart-3: oklch(0.45 0.12 230); /* shell */
106
106
-
--chart-4: oklch(0.55 0.12 230); /* shell */
107
107
-
--chart-5: oklch(0.7 0.14 350); /* coral */
115
115
+
--ring: oklch(0.72 0.18 350);
116
116
+
/* coral-400 #F99CA6 */
117
117
+
--chart-1: oklch(0.65 0.18 350);
118
118
+
/* coral */
119
119
+
--chart-2: oklch(0.55 0.15 350);
120
120
+
/* lipstick */
121
121
+
--chart-3: oklch(0.45 0.12 230);
122
122
+
/* shell */
123
123
+
--chart-4: oklch(0.55 0.12 230);
124
124
+
/* shell */
125
125
+
--chart-5: oklch(0.7 0.14 350);
126
126
+
/* coral */
108
127
--sidebar: oklch(0.95 0.01 240);
109
128
--sidebar-foreground: oklch(0.17 0.01 240);
110
129
--sidebar-primary: oklch(0.65 0.18 350);
···
116
135
}
117
136
118
137
.dark {
119
119
-
--background: oklch(0.17 0.01 240); /* shell-900 #101C26 */
120
120
-
--foreground: oklch(0.97 0.01 240); /* shell-50 #F5F7F9 */
121
121
-
--card: oklch(0.22 0.02 240); /* shell-800 #182938 */
138
138
+
--background: oklch(0.17 0.01 240);
139
139
+
/* shell-900 #101C26 */
140
140
+
--foreground: oklch(0.97 0.01 240);
141
141
+
/* shell-50 #F5F7F9 */
142
142
+
--card: oklch(0.22 0.02 240);
143
143
+
/* shell-800 #182938 */
122
144
--card-foreground: oklch(0.97 0.01 240);
123
123
-
--popover: oklch(0.22 0.02 240); /* shell-800 */
145
145
+
--popover: oklch(0.22 0.02 240);
146
146
+
/* shell-800 */
124
147
--popover-foreground: oklch(0.97 0.01 240);
125
125
-
--primary: oklch(0.72 0.18 350); /* coral-400 #F99CA6 */
148
148
+
--primary: oklch(0.72 0.18 350);
149
149
+
/* coral-400 #F99CA6 */
126
150
--primary-foreground: oklch(0.17 0.01 240);
127
127
-
--secondary: oklch(0.27 0.03 240); /* shell-700 #20374B */
151
151
+
--secondary: oklch(0.27 0.03 240);
152
152
+
/* shell-700 #20374B */
128
153
--secondary-foreground: oklch(0.95 0.01 240);
129
154
--muted: oklch(0.22 0.02 240);
130
130
-
--muted-foreground: oklch(0.55 0.1 230); /* shell-400 #728DA4 */
131
131
-
--accent: oklch(0.42 0.12 350); /* lipstick-700 #73414F */
132
132
-
--accent-foreground: oklch(0.96 0.02 350); /* lipstick-100 */
133
133
-
--destructive: oklch(0.65 0.18 350); /* coral-500 #F67280 */
134
134
-
--border: oklch(0.27 0.03 240); /* shell-700 */
155
155
+
--muted-foreground: oklch(0.55 0.1 230);
156
156
+
/* shell-400 #728DA4 */
157
157
+
--accent: oklch(0.42 0.12 350);
158
158
+
/* lipstick-700 #73414F */
159
159
+
--accent-foreground: oklch(0.96 0.02 350);
160
160
+
/* lipstick-100 */
161
161
+
--destructive: oklch(0.65 0.18 350);
162
162
+
/* coral-500 #F67280 */
163
163
+
--border: oklch(0.27 0.03 240);
164
164
+
/* shell-700 */
135
165
--input: oklch(0.27 0.03 240);
136
136
-
--ring: oklch(0.65 0.18 350); /* coral-500 */
166
166
+
--ring: oklch(0.65 0.18 350);
167
167
+
/* coral-500 */
137
168
--chart-1: oklch(0.72 0.18 350);
138
169
--chart-2: oklch(0.42 0.12 350);
139
170
--chart-3: oklch(0.27 0.03 240);
···
150
181
}
151
182
152
183
@layer base {
153
153
-
* {
154
154
-
@apply border-border outline-ring/50;
184
184
+
* {
185
185
+
@apply border-border outline-ring/50;
186
186
+
}
187
187
+
188
188
+
body {
189
189
+
@apply bg-background text-foreground;
155
190
}
156
156
-
body {
157
157
-
@apply bg-background text-foreground;
191
191
+
192
192
+
a {
193
193
+
@apply border-b border-coral-500/50;
158
194
}
195
195
+
159
196
}
+2
src/web/main.tsx
···
5
5
import './index.css'
6
6
import App from './app.tsx'
7
7
import Cumulus from './providers/cumulus-provider.tsx';
8
8
+
import { Toaster } from './components/ui/sonner.tsx';
8
9
9
10
const queryClient = new QueryClient();
10
11
···
17
18
</Cumulus>
18
19
</Auth>
19
20
</QueryClientProvider>
21
21
+
<Toaster />
20
22
</StrictMode>
21
23
)
+2
-1
src/web/providers/cumulus-provider.tsx
···
30
30
const { data, error } = await server.api.markets.get()
31
31
if (error) throw error;
32
32
return data as unknown as Market[];
33
33
-
}
33
33
+
},
34
34
+
refetchInterval: 30 * 1000,
34
35
});
35
36
36
37
return <CumulusContext.Provider value={{ markets }}>{children}</ CumulusContext.Provider>