Skip to content

Commit d05308b

Browse files
committed
add optimistic ui stuff
1 parent 5c00b6c commit d05308b

File tree

104 files changed

+3118
-39
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

104 files changed

+3118
-39
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ data.db
1010
/playground
1111
**/tsconfig.tsbuildinfo
1212

13+
/public/img/custom-ships
14+
1315
# in a real app you'd want to not commit the .env
1416
# file as well, but since this is for a workshop
1517
# we're going to keep them around.

epicshop/package-lock.json

Lines changed: 13 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

epicshop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"type": "module",
33
"dependencies": {
4-
"@epic-web/workshop-app": "^4.2.6",
4+
"@epic-web/workshop-app": "^4.2.8",
55
"execa": "^8.0.1"
66
}
77
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Optimistic UI
2+
3+
👨‍💼 TODO
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { getShip, searchShips, createShip } from '#shared/ship-api-utils.server'
2+
3+
export type Ship = Awaited<ReturnType<typeof getShip>>
4+
export type ShipSearch = Awaited<ReturnType<typeof searchShips>>
5+
6+
export async function loader({
7+
request,
8+
params,
9+
}: {
10+
request: Request
11+
params: Record<string, string>
12+
}) {
13+
const path = params['*']
14+
switch (path) {
15+
case 'search-ships': {
16+
const result = await searchShips(request)
17+
return new Response(JSON.stringify(result), {
18+
headers: {
19+
'content-type': 'application/json',
20+
},
21+
})
22+
}
23+
case 'get-ship': {
24+
const result = await getShip(request)
25+
return new Response(JSON.stringify(result), {
26+
headers: {
27+
'content-type': 'application/json',
28+
},
29+
})
30+
}
31+
default: {
32+
return new Response('Not found', { status: 404 })
33+
}
34+
}
35+
}
36+
37+
export async function action({
38+
request,
39+
params,
40+
}: {
41+
request: Request
42+
params: Record<string, string>
43+
}) {
44+
const path = params['*']
45+
switch (path) {
46+
case 'create-ship': {
47+
await new Promise(resolve => setTimeout(resolve, 2000))
48+
return createShip(request)
49+
}
50+
}
51+
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { Suspense, use, useState, useTransition } from 'react'
2+
import * as ReactDOM from 'react-dom/client'
3+
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'
4+
import { useSpinDelay } from 'spin-delay'
5+
import {
6+
// 💰 you're going to want this
7+
// type Ship,
8+
getShip,
9+
} from './utils.tsx'
10+
11+
function App() {
12+
const [shipName, setShipName] = useState('Dreadnought')
13+
const [isTransitionPending, startTransition] = useTransition()
14+
const isPending = useSpinDelay(isTransitionPending, {
15+
delay: 300,
16+
minDuration: 350,
17+
})
18+
// 🐨 add a useOptimistic call here
19+
// 🦺 The type should be a Ship | null, (initialized to null)
20+
21+
function handleShipSelection(newShipName: string) {
22+
startTransition(() => {
23+
setShipName(newShipName)
24+
})
25+
}
26+
27+
return (
28+
<div className="app-wrapper">
29+
<ShipButtons shipName={shipName} onShipSelect={handleShipSelection} />
30+
<div className="app">
31+
<div className="details" style={{ opacity: isPending ? 0.6 : 1 }}>
32+
<ErrorBoundary fallback={<ShipError shipName={shipName} />}>
33+
<Suspense fallback={<ShipFallback shipName={shipName} />}>
34+
{/* 🐨 pass our optimisticShip to ShipDetails here */}
35+
<ShipDetails shipName={shipName} />
36+
</Suspense>
37+
</ErrorBoundary>
38+
</div>
39+
</div>
40+
{/* 🐨 pass the setOptimisticShip function to CreateForm here */}
41+
<CreateForm setShipName={setShipName} />
42+
</div>
43+
)
44+
}
45+
46+
// 🐨 accept setOptimisticShip here
47+
function CreateForm({
48+
setShipName,
49+
}: {
50+
// 🦺 I'll give this one to you
51+
// setOptimisticShip: (ship: Ship | null) => void
52+
setShipName: (name: string) => void
53+
}) {
54+
return (
55+
<div>
56+
<details>
57+
<summary>Create a new ship</summary>
58+
<ErrorBoundary FallbackComponent={FormErrorFallback}>
59+
<form
60+
action={async formData => {
61+
// 🐨 create an optimistic ship based on the formData
62+
// using the createOptimisticShip utility below
63+
64+
// 🐨 set the optimistic ship
65+
66+
await fetch(`api/create-ship`, {
67+
method: 'POST',
68+
body: formData,
69+
}).then(async r => {
70+
if (!r.ok) return Promise.reject(new Error(await r.text()))
71+
})
72+
73+
setShipName(formData.get('name') as string)
74+
}}
75+
>
76+
<div>
77+
<label htmlFor="shipName">Ship Name</label>
78+
<input id="shipName" type="text" name="name" />
79+
</div>
80+
<div>
81+
<label htmlFor="topSpeed">Top Speed</label>
82+
<input id="topSpeed" type="number" name="topSpeed" />
83+
</div>
84+
<div>
85+
<label htmlFor="image">Image</label>
86+
<input id="image" type="file" name="image" accept="image/*" />
87+
</div>
88+
<div>
89+
<label>
90+
<input name="hyperdrive" type="checkbox" />
91+
Hyperdrive
92+
</label>
93+
</div>
94+
<button type="submit">Create</button>
95+
</form>
96+
</ErrorBoundary>
97+
</details>
98+
</div>
99+
)
100+
}
101+
102+
async function createOptimisticShip(formData: FormData) {
103+
return {
104+
name: formData.get('name') as string,
105+
topSpeed: Number(formData.get('topSpeed')),
106+
hyperdrive: formData.get('hyperdrive') === 'on',
107+
image: await fileToDataUrl(formData.get('image') as File),
108+
weapons: [],
109+
fetchedAt: '...',
110+
}
111+
}
112+
113+
function fileToDataUrl(file: File) {
114+
return new Promise<string>((resolve, reject) => {
115+
const reader = new FileReader()
116+
reader.onload = () => resolve(reader.result as string)
117+
reader.onerror = reject
118+
reader.readAsDataURL(file)
119+
})
120+
}
121+
122+
function FormErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
123+
return (
124+
<div role="alert">
125+
There was an error:{' '}
126+
<pre style={{ color: 'red', whiteSpace: 'normal' }}>{error.message}</pre>
127+
<button onClick={resetErrorBoundary}>Try again</button>
128+
</div>
129+
)
130+
}
131+
132+
function ShipButtons({
133+
shipName,
134+
onShipSelect,
135+
}: {
136+
shipName: string
137+
onShipSelect: (shipName: string) => void
138+
}) {
139+
const ships = ['Dreadnought', 'Interceptor', 'Galaxy Cruiser']
140+
141+
return (
142+
<div className="ship-buttons">
143+
{ships.map(ship => (
144+
<button
145+
key={ship}
146+
onClick={() => onShipSelect(ship)}
147+
className={shipName === ship ? 'active' : ''}
148+
>
149+
{ship}
150+
</button>
151+
))}
152+
</div>
153+
)
154+
}
155+
156+
// 🐨 accept an optimisticShip prop here
157+
function ShipDetails({ shipName }: { shipName: string }) {
158+
// 🦉 you can change this delay to control how long loading the resource takes:
159+
const delay = 2000
160+
// 🐨 if we have an optimisticShip, set the ship to that instead
161+
const ship = use(getShip(shipName, delay))
162+
return (
163+
<div className="ship-info">
164+
<div className="ship-info__img-wrapper">
165+
<img src={ship.image} alt={ship.name} />
166+
</div>
167+
<section>
168+
<h2>
169+
{ship.name}
170+
<sup>
171+
{ship.topSpeed} <small>lyh</small>
172+
</sup>
173+
</h2>
174+
</section>
175+
<section>
176+
{ship.weapons.length ? (
177+
<ul>
178+
{ship.weapons.map(weapon => (
179+
<li key={weapon.name}>
180+
<label>{weapon.name}</label>:{' '}
181+
<span>
182+
{weapon.damage} <small>({weapon.type})</small>
183+
</span>
184+
</li>
185+
))}
186+
</ul>
187+
) : (
188+
<p>NOTE: This ship is not equipped with any weapons.</p>
189+
)}
190+
</section>
191+
<small className="ship-info__fetch-time">{ship.fetchedAt}</small>
192+
</div>
193+
)
194+
}
195+
196+
function ShipFallback({ shipName }: { shipName: string }) {
197+
return (
198+
<div className="ship-info">
199+
<div className="ship-info__img-wrapper">
200+
<img src="/img/fallback-ship.png" alt={shipName} />
201+
</div>
202+
<section>
203+
<h2>
204+
{shipName}
205+
<sup>
206+
XX <small>lyh</small>
207+
</sup>
208+
</h2>
209+
</section>
210+
<section>
211+
<ul>
212+
{Array.from({ length: 3 }).map((_, i) => (
213+
<li key={i}>
214+
<label>loading</label>:{' '}
215+
<span>
216+
XX <small>(loading)</small>
217+
</span>
218+
</li>
219+
))}
220+
</ul>
221+
</section>
222+
</div>
223+
)
224+
}
225+
226+
function ShipError({ shipName }: { shipName: string }) {
227+
return (
228+
<div className="ship-info">
229+
<div className="ship-info__img-wrapper">
230+
<img src="/img/broken-ship.webp" alt="broken ship" />
231+
</div>
232+
<section>
233+
<h2>There was an error</h2>
234+
</section>
235+
<section>There was an error loading "{shipName}"</section>
236+
</div>
237+
)
238+
}
239+
240+
const rootEl = document.createElement('div')
241+
document.body.append(rootEl)
242+
ReactDOM.createRoot(rootEl).render(<App />)

0 commit comments

Comments
 (0)