Skip to content

Commit ac058ad

Browse files
committed
feat: enhance journal viewer with delete and summarize entry functionality, improve error handling, and update styles
1 parent 7543b2a commit ac058ad

File tree

5 files changed

+233
-23
lines changed

5 files changed

+233
-23
lines changed

exercises/99.final/99.solution/app/routes/ui/journal-viewer.tsx

Lines changed: 167 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
import { invariant } from '@epic-web/invariant'
12
import { useTransition } from 'react'
23
import {
34
ErrorBoundary,
45
useErrorBoundary,
56
type FallbackProps,
67
} from 'react-error-boundary'
7-
import { useMcpUiInit, navigateToLink } from '#app/utils/mcp.ts'
8+
import { useRevalidator } from 'react-router'
9+
import { z } from 'zod'
10+
import {
11+
useMcpUiInit,
12+
navigateToLink,
13+
callTool,
14+
sendPrompt,
15+
} from '#app/utils/mcp.ts'
16+
import { useDoubleCheck } from '#app/utils/misc.ts'
817
import { type Route } from './+types/journal-viewer.tsx'
918

1019
export async function loader({ context }: Route.LoaderArgs) {
@@ -18,7 +27,7 @@ export default function JournalViewer({ loaderData }: Route.ComponentProps) {
1827
useMcpUiInit()
1928

2029
return (
21-
<div className="bg-background min-h-screen p-4">
30+
<div className="bg-background max-h-[800px] overflow-y-auto p-4">
2231
<div className="mx-auto max-w-4xl">
2332
<div className="bg-card mb-6 rounded-xl p-6 shadow-lg">
2433
<h1 className="text-foreground mb-2 text-3xl font-bold">
@@ -68,10 +77,12 @@ export default function JournalViewer({ loaderData }: Route.ComponentProps) {
6877
</span>
6978
</div>
7079

71-
<div className="mt-4">
80+
<div className="mt-4 flex gap-2">
7281
<button className="text-primary text-sm font-medium hover:underline">
7382
View Details
7483
</button>
84+
<SummarizeEntryButton entry={entry} />
85+
<DeleteEntryButton entry={entry} />
7586
</div>
7687
</div>
7788
</div>
@@ -96,10 +107,10 @@ function XPostLinkError({ error, resetErrorBoundary }: FallbackProps) {
96107
return (
97108
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border p-3">
98109
<p className="text-sm font-medium">Failed to post on X</p>
99-
<p className="text-xs text-destructive/80">{error.message}</p>
110+
<p className="text-destructive/80 text-xs">{error.message}</p>
100111
<button
101112
onClick={resetErrorBoundary}
102-
className="mt-2 text-xs text-destructive hover:underline cursor-pointer"
113+
className="text-destructive mt-2 cursor-pointer text-xs hover:underline"
103114
>
104115
Try again
105116
</button>
@@ -128,12 +139,161 @@ function XPostLinkImpl({ entryCount }: { entryCount: number }) {
128139
<button
129140
onClick={handlePostOnX}
130141
disabled={isPending}
131-
className="bg-black text-white px-4 py-2 rounded-lg hover:bg-gray-800 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors flex items-center gap-2 cursor-pointer"
142+
className="flex cursor-pointer items-center gap-2 rounded-lg bg-black px-4 py-2 text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-400"
132143
>
133-
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
144+
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
134145
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
135146
</svg>
136147
{isPending ? 'Posting...' : 'Post'}
137148
</button>
138149
)
139150
}
151+
152+
function DeleteEntryButton({
153+
entry,
154+
}: {
155+
entry: { id: number; title: string }
156+
}) {
157+
return (
158+
<ErrorBoundary FallbackComponent={DeleteEntryError}>
159+
<DeleteEntryButtonImpl entry={entry} />
160+
</ErrorBoundary>
161+
)
162+
}
163+
164+
function DeleteEntryError({ error, resetErrorBoundary }: FallbackProps) {
165+
return (
166+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border p-3">
167+
<p className="text-sm font-medium">Failed to delete entry</p>
168+
<p className="text-destructive/80 text-xs">{error.message}</p>
169+
<button
170+
onClick={resetErrorBoundary}
171+
className="text-destructive mt-2 cursor-pointer text-xs hover:underline"
172+
>
173+
Try again
174+
</button>
175+
</div>
176+
)
177+
}
178+
179+
function DeleteEntryButtonImpl({
180+
entry,
181+
}: {
182+
entry: { id: number; title: string }
183+
}) {
184+
const [isPending, startTransition] = useTransition()
185+
const { doubleCheck, getButtonProps } = useDoubleCheck()
186+
const { showBoundary } = useErrorBoundary()
187+
const revalidator = useRevalidator()
188+
189+
const handleDelete = () => {
190+
startTransition(async () => {
191+
try {
192+
await callTool('delete_entry', { id: entry.id })
193+
await revalidator.revalidate()
194+
} catch (err) {
195+
showBoundary(err)
196+
}
197+
})
198+
}
199+
200+
return (
201+
<button
202+
{...getButtonProps({
203+
onClick: doubleCheck ? handleDelete : undefined,
204+
disabled: isPending,
205+
className: `text-sm font-medium px-3 py-1.5 rounded-md border transition-colors ${
206+
doubleCheck
207+
? 'bg-destructive text-destructive-foreground border-destructive hover:bg-destructive/90'
208+
: 'text-destructive border-destructive/20 hover:bg-destructive/10 hover:border-destructive/40'
209+
} ${isPending ? 'opacity-50 cursor-not-allowed' : ''}`,
210+
})}
211+
>
212+
{isPending ? 'Deleting...' : doubleCheck ? `Confirm?` : 'Delete'}
213+
</button>
214+
)
215+
}
216+
217+
function SummarizeEntryButton({
218+
entry,
219+
}: {
220+
entry: { id: number; title: string }
221+
}) {
222+
return (
223+
<ErrorBoundary FallbackComponent={SummarizeEntryError}>
224+
<SummarizeEntryButtonImpl entry={entry} />
225+
</ErrorBoundary>
226+
)
227+
}
228+
229+
function SummarizeEntryError({ error, resetErrorBoundary }: FallbackProps) {
230+
return (
231+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border p-3">
232+
<p className="text-sm font-medium">Failed to summarize entry</p>
233+
<p className="text-destructive/80 text-xs">{error.message}</p>
234+
<button
235+
onClick={resetErrorBoundary}
236+
className="text-destructive mt-2 cursor-pointer text-xs hover:underline"
237+
>
238+
Try again
239+
</button>
240+
</div>
241+
)
242+
}
243+
244+
function SummarizeEntryButtonImpl({
245+
entry,
246+
}: {
247+
entry: { id: number; title: string }
248+
}) {
249+
const [isPending, startTransition] = useTransition()
250+
const { showBoundary } = useErrorBoundary()
251+
252+
const handleSummarize = () => {
253+
startTransition(async () => {
254+
try {
255+
// Get the full entry content first
256+
const fullEntry = await callTool('get_entry', { id: entry.id })
257+
console.log({ fullEntry })
258+
invariant(fullEntry, 'Failed to retrieve entry content')
259+
const entrySchema = z.object({
260+
title: z.string(),
261+
content: z.string(),
262+
mood: z.string().optional(),
263+
location: z.string().optional(),
264+
weather: z.string().optional(),
265+
tags: z
266+
.array(z.object({ id: z.number(), name: z.string() }))
267+
.optional(),
268+
})
269+
const parsedEntry = entrySchema.parse(fullEntry)
270+
271+
// Create a prompt requesting a summary
272+
const prompt = `Please provide a concise summary of this journal entry:
273+
274+
Title: ${parsedEntry.title}
275+
Content: ${parsedEntry.content}
276+
Mood: ${parsedEntry.mood || 'Not specified'}
277+
Location: ${parsedEntry.location || 'Not specified'}
278+
Weather: ${parsedEntry.weather || 'Not specified'}
279+
Tags: ${parsedEntry.tags?.map((t: { name: string }) => t.name).join(', ') || 'None'}
280+
281+
Please provide a brief, insightful summary of this entry.`
282+
283+
await sendPrompt(prompt)
284+
} catch (err) {
285+
showBoundary(err)
286+
}
287+
})
288+
}
289+
290+
return (
291+
<button
292+
onClick={handleSummarize}
293+
disabled={isPending}
294+
className="text-primary text-sm font-medium hover:underline disabled:cursor-not-allowed disabled:opacity-50"
295+
>
296+
{isPending ? 'Summarizing...' : 'Summarize'}
297+
</button>
298+
)
299+
}

exercises/99.final/99.solution/app/utils/mcp.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function useFormSubmissionCapability() {
6262
return canUseOnSubmit
6363
}
6464

65-
function createMcpMessageHandler<T>(
65+
function createMcpMessageHandler<T extends unknown>(
6666
type: string,
6767
payload: Record<string, unknown>,
6868
signal?: AbortSignal,
@@ -99,24 +99,24 @@ function createMcpMessageHandler<T>(
9999
})
100100
}
101101

102-
export function callTool(
102+
export function callTool<ReturnType extends unknown>(
103103
toolName: string,
104104
params: any,
105105
signal?: AbortSignal,
106-
): Promise<any> {
106+
): Promise<ReturnType> {
107107
return createMcpMessageHandler('tool', { toolName, params }, signal)
108108
}
109109

110-
export function sendPrompt(
110+
export function sendPrompt<ReturnType extends unknown>(
111111
prompt: string,
112112
signal?: AbortSignal,
113-
): Promise<void> {
113+
): Promise<ReturnType> {
114114
return createMcpMessageHandler('prompt', { prompt }, signal)
115115
}
116116

117-
export function navigateToLink(
117+
export function navigateToLink<ReturnType extends unknown>(
118118
url: string,
119119
signal?: AbortSignal,
120-
): Promise<void> {
120+
): Promise<ReturnType> {
121121
return createMcpMessageHandler('link', { url }, signal)
122122
}

exercises/99.final/99.solution/app/utils/misc.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useState } from 'react'
2+
13
export function getErrorMessage(error: unknown) {
24
if (typeof error === 'string') return error
35
if (
@@ -11,3 +13,51 @@ export function getErrorMessage(error: unknown) {
1113
console.error('Unable to get error message for error', error)
1214
return 'Unknown Error'
1315
}
16+
17+
function callAll<Args extends Array<unknown>>(
18+
...fns: Array<((...args: Args) => unknown) | undefined>
19+
) {
20+
return (...args: Args) => fns.forEach((fn) => fn?.(...args))
21+
}
22+
23+
/**
24+
* Use this hook with a button and it will make it so the first click sets a
25+
* `doubleCheck` state to true, and the second click will actually trigger the
26+
* `onClick` handler. This allows you to have a button that can be like a
27+
* "are you sure?" experience for the user before doing destructive operations.
28+
*/
29+
export function useDoubleCheck() {
30+
const [doubleCheck, setDoubleCheck] = useState(false)
31+
32+
function getButtonProps(
33+
props?: React.ButtonHTMLAttributes<HTMLButtonElement>,
34+
) {
35+
const onBlur: React.ButtonHTMLAttributes<HTMLButtonElement>['onBlur'] =
36+
() => setDoubleCheck(false)
37+
38+
const onClick: React.ButtonHTMLAttributes<HTMLButtonElement>['onClick'] =
39+
doubleCheck
40+
? undefined
41+
: (e) => {
42+
e.preventDefault()
43+
setDoubleCheck(true)
44+
}
45+
46+
const onKeyUp: React.ButtonHTMLAttributes<HTMLButtonElement>['onKeyUp'] = (
47+
e,
48+
) => {
49+
if (e.key === 'Escape') {
50+
setDoubleCheck(false)
51+
}
52+
}
53+
54+
return {
55+
...props,
56+
onBlur: callAll(onBlur, props?.onBlur),
57+
onClick: callAll(onClick, props?.onClick),
58+
onKeyUp: callAll(onKeyUp, props?.onKeyUp),
59+
}
60+
}
61+
62+
return { doubleCheck, getButtonProps }
63+
}

exercises/99.final/99.solution/worker/mcp/tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export async function initializeTools(agent: EpicMeMCP) {
7777
uri: `ui://view-journal/${Date.now()}`,
7878
content: {
7979
type: 'externalUrl',
80-
iframeUrl: `${agent.props.baseUrl}/journal`,
80+
iframeUrl: `${agent.props.baseUrl}/ui/journal-viewer`,
8181
},
8282
encoding: 'text',
8383
}),

exercises/99.final/99.solution/worker/mcp/ui.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ export async function getTagViewUI(db: DBClient, tagId: number) {
1111
<title>Epic Me</title>
1212
<style>
1313
* { margin: 0; padding: 0; box-sizing: border-box; }
14-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background-color: #f8fafc; }
14+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: oklch(25% 0.05 60); background-color: oklch(98% 0.02 60); }
1515
.container { max-width: 1200px; margin: 0 auto; padding: 2rem 1rem; }
16-
.title { font-size: 2rem; font-weight: 700; color: #1e293b; margin-bottom: 2rem; text-align: center; }
17-
.description { text-align: center; color: #64748b; }
16+
.title { font-size: 2rem; font-weight: 700; color: oklch(25% 0.05 60); margin-bottom: 2rem; text-align: center; }
17+
.description { text-align: center; color: oklch(45% 0.06 60); }
1818
</style>
1919
</head>
2020
<body>
@@ -35,13 +35,13 @@ export async function getTagViewUI(db: DBClient, tagId: number) {
3535
<title>Epic Me</title>
3636
<style>
3737
* { margin: 0; padding: 0; box-sizing: border-box; }
38-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background-color: #f8fafc; }
38+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: oklch(25% 0.05 60); background-color: oklch(98% 0.02 60); }
3939
.container { max-width: 1200px; margin: 0 auto; padding: 2rem 1rem; }
40-
.title { font-size: 2rem; font-weight: 700; color: #1e293b; margin-bottom: 2rem; text-align: center; }
41-
.description { text-align: center; color: #64748b; }
40+
.title { font-size: 2rem; font-weight: 700; color: oklch(25% 0.05 60); margin-bottom: 2rem; text-align: center; }
41+
.description { text-align: center; color: oklch(45% 0.06 60); }
4242
.error-state { text-align: center; padding: 4rem 2rem; }
43-
.error-state h1 { margin: 1rem 0 2rem; color: #1e293b; }
44-
.error-icon { color: #ef4444; width: 3rem; height: 3rem; display: inline-flex; align-items: center; justify-content: center; }
43+
.error-state h1 { margin: 1rem 0 2rem; color: oklch(25% 0.05 60); }
44+
.error-icon { color: oklch(60% 0.22 25); width: 3rem; height: 3rem; display: inline-flex; align-items: center; justify-content: center; }
4545
.error-icon svg { width: 100%; height: 100%; }
4646
</style>
4747
</head>

0 commit comments

Comments
 (0)