Skip to content

Commit dcd1eed

Browse files
committed
feat(cli): handle errors and make it fancy
1 parent 1fee627 commit dcd1eed

16 files changed

+833
-358
lines changed

.github/workflows/cli-release.yaml

+5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ jobs:
1919
fetch-depth: 0
2020
fetch-tags: false
2121

22+
- name: 🗑️ Make sure Bun does not try to look up
23+
run: |
24+
rm bun.lock
25+
rm package.json
26+
2227
- name: ⎔ Setup bun
2328
uses: oven-sh/setup-bun@v2
2429

atw-cli/bun.lock

+523-6
Large diffs are not rendered by default.

atw-cli/package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "atw-cli",
33
"module": "index.ts",
44
"type": "module",
5-
"version": "1.0.0",
5+
"version": "1.1.0",
66
"private": true,
77
"devDependencies": {
88
"@types/bun": "latest",
@@ -16,7 +16,7 @@
1616
"commander": "^13.1.0",
1717
"ink": "^5.1.1",
1818
"ink-link": "^4.1.0",
19-
"lib": "workspace:*",
19+
"@rocicorp/zero": "^0.16.2025022602",
2020
"picocolors": "^1.1.1",
2121
"react": "^18",
2222
"react-devtools-core": "^6.1.1"
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
1+
import { ATW_API_BASEURL } from "../config"
12

23
export type Event = {
34
id: string
45
slug: string
56
name: string
6-
startDate: string
7+
startDate: number
78
shortLocation: string|null
89
}
910

1011
export const registerAction = async (email: string, eventId: Event['id']) => {
11-
const response = await fetch(`https://allthingsweb.dev/api/events/${eventId}/register`, {
12+
const response = await fetch(`${ATW_API_BASEURL}/events/${eventId}/register`, {
1213
method: 'POST',
1314
body: JSON.stringify({ email })
1415
})
15-
if (!response.ok) {
16-
throw new Error('Failed to register')
17-
}
18-
const { success } = await response.json()
19-
return success
16+
return await response.json()
2017
}

atw-cli/src/commands/register.tsx

+5-26
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,24 @@
11
import { Command } from "commander";
2-
import { Box, Newline, render, Text } from 'ink';
3-
import { FooterSpeakers } from "../ui/speaker";
4-
import { RegisterJourney } from "../ui/register-journey";
2+
import { render } from 'ink';
3+
import { RegisterJourney } from "../ui/journeys/register";
54
import pc from "picocolors";
6-
import { ZeroProvider } from "@rocicorp/zero/react";
7-
import { Zero } from "@rocicorp/zero";
85
import { logo } from "..";
9-
import { schema } from "@lib/zero-sync/schema";
6+
import { AppLayout } from "../ui/app-layout";
107

118
export const createRegisterCommand = (): Command => {
129
const command = new Command("register")
1310
.description("Register to an event.")
1411
.action(async () => {
1512
console.log(pc.dim(logo));
16-
const z = new Zero({
17-
userID: "anon",
18-
server: 'https://allthingsweb-sync.fly.dev',
19-
schema,
20-
kvStore: 'mem',
21-
});
2213
const { waitUntilExit, unmount } = render(
23-
<ZeroProvider zero={z}>
24-
<>
25-
<Text bold italic>Register to an event!</Text>
14+
<AppLayout title="Register to an event!">
2615
<RegisterJourney unmount={() => unmount()} />
27-
<Newline />
28-
<Box flexDirection="column" padding={1} gap={1}>
29-
<FooterSpeakers />
30-
</Box>
31-
</>
32-
</ZeroProvider>,
16+
</AppLayout>,
3317
{
3418
exitOnCtrlC: true,
3519
}
3620
);
3721
await waitUntilExit();
38-
39-
40-
render(<Box flexDirection="column" padding={1} gap={1}>
41-
<Text>Register to an event!</Text>
42-
</Box>)
4322
});
4423
return command;
4524
}

atw-cli/src/config.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const ATW_BASEURL = process.env.AWT_BASEURL || "https://allthingsweb.dev";
2+
export const ATW_API_BASEURL = process.env.ATW_API_BASEURL || `${ATW_BASEURL}/api`;

atw-cli/src/ui/app-layout.tsx

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Box} from 'ink';
2+
import { ZeroProvider } from "@rocicorp/zero/react";
3+
import { Zero } from "@rocicorp/zero";
4+
import { schema } from "@lib/zero-sync/schema";
5+
import { ThemeProvider} from '@inkjs/ui';
6+
import type React from "react";
7+
import { customTheme } from './theme';
8+
import { Header } from './components/header';
9+
import { Footer } from './footer';
10+
11+
type AppLayoutProps = {
12+
children: React.ReactNode;
13+
title: string
14+
}
15+
16+
export const AppLayout = ({children, title}: AppLayoutProps): React.ReactNode => {
17+
const z = new Zero({
18+
userID: "anon",
19+
server: 'https://allthingsweb-sync.fly.dev',
20+
schema,
21+
kvStore: 'mem',
22+
});
23+
return <ZeroProvider zero={z}>
24+
<ThemeProvider theme={customTheme}>
25+
<Header level={1}>{title}</Header>
26+
<Box flexDirection="column" padding={1}>{children}</Box>
27+
<Box flexDirection="column">
28+
<Footer />
29+
</Box>
30+
31+
</ThemeProvider>
32+
</ZeroProvider>
33+
34+
35+
}

atw-cli/src/ui/components/header.tsx

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Box, Text } from "ink";
2+
import { colors } from "../theme";
3+
4+
type HeaderProps = {
5+
level?: 1 | 2;
6+
children: React.ReactNode;
7+
}
8+
9+
export const Header = ({ level = 1, children }: HeaderProps) => {
10+
const colorMap = {
11+
1: colors.mainBlue,
12+
2: '#FF6600',
13+
}
14+
return <Box
15+
flexDirection="row"
16+
borderStyle={"single"}
17+
borderTop={false}
18+
borderLeft={false}
19+
borderRight={false}
20+
borderBottom={true}
21+
borderBottomColor={colors.mainBlue}
22+
><Text bold italic color={colorMap[level]}>{children}</Text></Box>
23+
}

atw-cli/src/ui/footer.tsx

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { schema } from "@lib/zero-sync/schema";
2+
import { useQuery, useZero } from "@rocicorp/zero/react";
3+
import { Newline, Text, Box } from "ink"
4+
import Link from "ink-link"
5+
import { useEffect, useState } from "react"
6+
import { colors } from "./theme";
7+
import { Spinner } from "@inkjs/ui";
8+
9+
export const Footer = () => {
10+
const z = useZero<typeof schema>();
11+
const [speakers] = useQuery(z.query.profiles);
12+
const [speakerIndex, setSpeakerIndex] = useState(Math.floor(Math.random() * 10) + 1)
13+
14+
useEffect(() => {
15+
const interval = setInterval(() => {
16+
setSpeakerIndex((speakerIndex + 1) % speakers.length)
17+
}, 2000)
18+
return () => clearInterval(interval)
19+
}, [speakerIndex, speakers.length])
20+
21+
const speaker = speakers[speakerIndex]
22+
23+
const link = (()=>{
24+
if (speaker?.linkedinHandle) {
25+
return `https://www.linkedin.com/in/${speaker.linkedinHandle}`
26+
}
27+
if (speaker?.twitterHandle) {
28+
return `https://x.com/${speaker.twitterHandle}`
29+
}
30+
if (speaker?.blueskyHandle) {
31+
return `https://bsky.app/profile/${speaker.blueskyHandle}`
32+
}
33+
})();
34+
return (
35+
<>
36+
<Newline />
37+
<Text dimColor>------------------------ </Text>
38+
<Box>
39+
{!speaker && <Spinner type='dots10' label="Loading speakers..." />}
40+
{speaker && <Text italic dimColor>
41+
<Text>
42+
{speaker.name} - <Text color={colors.mainOrange} dimColor>{speaker?.title}</Text>
43+
<Newline />
44+
{link && <Link url={link}>
45+
<Text color={colors.mainOrange} dimColor>{link}</Text>
46+
</Link>}
47+
</Text>
48+
</Text>}
49+
</Box>
50+
</>
51+
)
52+
}
+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
2+
import { type Event } from "../../../actions/register"
3+
type State = {
4+
event: Event | null
5+
hasSucceeded: boolean | null
6+
isSubmitting: boolean
7+
error: string | null
8+
email: string | null
9+
}
10+
11+
export const initialState: State = {
12+
event: null,
13+
hasSucceeded: null,
14+
isSubmitting: false,
15+
error: null,
16+
email: null
17+
}
18+
19+
type Action = {
20+
type: 'SELECT_EVENT'
21+
event: Event
22+
} | {
23+
type: 'SET_EMAIL'
24+
email: string
25+
} | {
26+
type: 'HANDLE_API_RESULTS'
27+
results: {
28+
error?: string
29+
success?: boolean
30+
}
31+
}
32+
33+
export const toDate = (date: number) => `${new Date(date).toLocaleDateString()} ${new Date(date).toLocaleTimeString()}`
34+
35+
export const reducer = (state: State, action: Action): State => {
36+
switch (action.type) {
37+
case 'SELECT_EVENT':
38+
return {
39+
...state,
40+
event: action.event,
41+
}
42+
case 'SET_EMAIL':
43+
return {
44+
...state,
45+
isSubmitting: true,
46+
email: action.email
47+
}
48+
case 'HANDLE_API_RESULTS':
49+
console.log({action})
50+
return {
51+
...state,
52+
isSubmitting: false,
53+
hasSucceeded: action.results.success || false,
54+
error: action.results.error || null
55+
}
56+
default:
57+
return state
58+
}
59+
}
+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Box, Text } from "ink"
2+
import { useReducer } from "react"
3+
import { Select } from "../../components/select"
4+
import { Badge, EmailInput, Spinner } from '@inkjs/ui';
5+
6+
import { registerAction, type Event } from "../../../actions/register"
7+
import Link from "ink-link";
8+
import { useQuery, useZero } from "@rocicorp/zero/react";
9+
import { schema } from "@lib/zero-sync/schema";
10+
import { colors } from "../../theme";
11+
import { initialState, reducer, toDate } from "./core";
12+
import { ATW_BASEURL } from "../../../config";
13+
14+
type RegisterJourneyProps = {
15+
unmount: () => void
16+
}
17+
18+
export const RegisterJourney = ({ unmount }: RegisterJourneyProps) => {
19+
const z = useZero<typeof schema>();
20+
const [events] = useQuery(z.query.events.where('startDate', '>', (new Date()).getTime()).orderBy('startDate', 'desc').limit(10))
21+
const [state, dispatch] = useReducer(reducer, initialState);
22+
const { event, hasSucceeded, isSubmitting, error } = state
23+
const isLoading = events.length <= 0
24+
return <Box flexDirection="column" padding={1}>
25+
{isLoading && <Spinner type='aesthetic' label="Loading events..." />}
26+
{!event && !isLoading && <Text>Here is the list of the upcomping events:</Text>}
27+
{!event && <Select<Event> options={events.map((event) => {
28+
const link = `${ATW_BASEURL}/${event.slug}`
29+
return {
30+
label: event.name,
31+
value: event,
32+
render: (label, value, isSelected) => <Box>
33+
<Box flexDirection="column">
34+
<Text bold={isSelected} color={isSelected ? colors.mainPurple : undefined} dimColor={!isSelected}>{label}</Text>
35+
<Text dimColor={!isSelected}>{toDate(event.startDate)} - {value.shortLocation}</Text>
36+
<Link url={link}>
37+
<Text color={colors.mainBlue} dimColor={!isSelected}>{link}</Text>
38+
</Link>
39+
</Box>
40+
</Box>
41+
}
42+
})} onSelect={(event) => {
43+
dispatch({ type: 'SELECT_EVENT', event })
44+
}} />}
45+
46+
{event && <Box flexDirection="column" padding={1} gap={1}>
47+
<Box flexDirection="column">
48+
<Text bold color={colors.mainPurple}>{event.name}</Text>
49+
<Text>{toDate(event.startDate)} - {event.shortLocation}</Text>
50+
<Link url={`${ATW_BASEURL}/${event.slug}`}>
51+
<Text color={colors.mainBlue}>{`${ATW_BASEURL}/${event.slug}`}</Text>
52+
</Link>
53+
</Box>
54+
55+
{hasSucceeded === null && !isSubmitting && <>
56+
<Text>To finalize registration, please enter your email address:</Text>
57+
<EmailInput
58+
domains={['gmail.com', 'yahoo.com', 'outlook.com', 'icloud.com', 'hotmail.com', 'protonmail.com']}
59+
placeholder="Your email..."
60+
onSubmit={async (email) => {
61+
dispatch({ type: 'SET_EMAIL', email })
62+
const results = await registerAction(email, event.id)
63+
dispatch({ type: 'HANDLE_API_RESULTS', results })
64+
setTimeout(() => {
65+
unmount()
66+
}, 2000)
67+
}}
68+
/>
69+
</>}
70+
</Box>}
71+
{isSubmitting && <Box flexDirection="row" gap={2} padding={2}>
72+
<Badge color="yellow">Registrating...</Badge><Text dimColor>{state.email}</Text><Spinner type='bouncingBall' label="Hang tight!" />
73+
</Box>}
74+
{hasSucceeded === true && <Box flexDirection="row" gap={2} padding={2}>
75+
<Badge color="green">Registration Successful!</Badge><Spinner type='fingerDance' label="See you there" /><Text color={colors.mainOrange}>{state.email}</Text>
76+
</Box>}
77+
{hasSucceeded === false && <Box flexDirection="row" gap={2} padding={2}>
78+
<Badge color="red">Registration Failed!</Badge><Spinner type='monkey' label={error || 'Something went wrong!'} />
79+
</Box>}
80+
</Box>
81+
}

0 commit comments

Comments
 (0)