Skip to content

Commit f3e3909

Browse files
committed
fix: adding TanStack DB add-on
1 parent 25285e8 commit f3e3909

File tree

10 files changed

+340
-1
lines changed

10 files changed

+340
-1
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
3+
import { useChat, useMessages } from '@/hooks/demo.useChat'
4+
5+
import Messages from './demo.messages'
6+
7+
export default function ChatArea() {
8+
const messagesEndRef = useRef<HTMLDivElement>(null)
9+
10+
const { sendMessage } = useChat()
11+
12+
const messages = useMessages()
13+
14+
const [message, setMessage] = useState('')
15+
const [user, setUser] = useState('Alice')
16+
17+
useEffect(() => {
18+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
19+
}, [messages])
20+
21+
const postMessage = () => {
22+
if (message.trim().length) {
23+
sendMessage(message, user)
24+
setMessage('')
25+
}
26+
}
27+
28+
const handleKeyPress = (e: React.KeyboardEvent) => {
29+
if (e.key === 'Enter') {
30+
postMessage()
31+
}
32+
}
33+
34+
return (
35+
<>
36+
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-4">
37+
<Messages messages={messages} user={user} />
38+
<div ref={messagesEndRef} />
39+
</div>
40+
41+
<div className="bg-white border-t border-gray-200 px-4 py-4">
42+
<div className="flex items-center space-x-3">
43+
<select
44+
value={user}
45+
onChange={(e) => setUser(e.target.value)}
46+
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
47+
>
48+
<option value="Alice">Alice</option>
49+
<option value="Bob">Bob</option>
50+
</select>
51+
52+
<div className="flex-1 relative">
53+
<input
54+
type="text"
55+
value={message}
56+
onChange={(e) => setMessage(e.target.value)}
57+
onKeyDown={handleKeyPress}
58+
placeholder="Type a message..."
59+
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
60+
/>
61+
</div>
62+
63+
<button
64+
onClick={postMessage}
65+
disabled={message.trim() === ''}
66+
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
67+
>
68+
Send
69+
</button>
70+
</div>
71+
</div>
72+
</>
73+
)
74+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { Message } from '@/db-collections'
2+
3+
export const getAvatarColor = (username: string) => {
4+
const colors = [
5+
'bg-blue-500',
6+
'bg-green-500',
7+
'bg-purple-500',
8+
'bg-pink-500',
9+
'bg-indigo-500',
10+
'bg-red-500',
11+
'bg-yellow-500',
12+
'bg-teal-500',
13+
]
14+
const index = username
15+
.split('')
16+
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
17+
return colors[index % colors.length]
18+
}
19+
20+
export default function Messages({
21+
messages,
22+
user,
23+
}: {
24+
messages: Message[]
25+
user: string
26+
}) {
27+
return (
28+
<>
29+
{messages.map((msg: Message) => (
30+
<div
31+
key={msg.id}
32+
className={`flex ${
33+
msg.user === user ? 'justify-end' : 'justify-start'
34+
}`}
35+
>
36+
<div
37+
className={`flex items-start space-x-3 max-w-xs lg:max-w-md ${
38+
msg.user === user ? 'flex-row-reverse space-x-reverse' : ''
39+
}`}
40+
>
41+
<div
42+
className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium ${getAvatarColor(
43+
msg.user,
44+
)}`}
45+
>
46+
{msg.user.charAt(0).toUpperCase()}
47+
</div>
48+
49+
<div
50+
className={`px-4 py-2 rounded-2xl ${
51+
msg.user === user
52+
? 'bg-blue-500 text-white rounded-br-md'
53+
: 'bg-white text-gray-800 border border-gray-200 rounded-bl-md'
54+
}`}
55+
>
56+
{msg.user !== user && (
57+
<p className="text-xs text-gray-500 mb-1 font-medium">
58+
{msg.user}
59+
</p>
60+
)}
61+
<p className="text-sm">{msg.text}</p>
62+
</div>
63+
</div>
64+
</div>
65+
))}
66+
</>
67+
)
68+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {
2+
createCollection,
3+
localOnlyCollectionOptions,
4+
} from "@tanstack/react-db";
5+
import { z } from "zod";
6+
7+
const MessageSchema = z.object({
8+
id: z.number(),
9+
text: z.string(),
10+
user: z.string(),
11+
});
12+
13+
export type Message = z.infer<typeof MessageSchema>;
14+
15+
export const messagesCollection = createCollection(
16+
localOnlyCollectionOptions({
17+
getKey: (message) => message.id,
18+
schema: MessageSchema,
19+
})
20+
);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useEffect, useRef } from 'react'
2+
import { useLiveQuery } from '@tanstack/react-db'
3+
4+
import { messagesCollection, type Message } from '@/db-collections'
5+
6+
import type { Collection } from '@tanstack/db'
7+
8+
function useStreamConnection(
9+
url: string,
10+
collection: Collection<any, any, any>,
11+
) {
12+
const loadedRef = useRef(false)
13+
14+
useEffect(() => {
15+
const fetchData = async () => {
16+
if (loadedRef.current) return
17+
loadedRef.current = true
18+
19+
const response = await fetch(url)
20+
const reader = response.body?.getReader()
21+
if (!reader) {
22+
return
23+
}
24+
25+
const decoder = new TextDecoder()
26+
while (true) {
27+
const { done, value } = await reader.read()
28+
if (done) break
29+
for (const chunk of decoder
30+
.decode(value, { stream: true })
31+
.split('\n')
32+
.filter((chunk) => chunk.length > 0)) {
33+
collection.insert(JSON.parse(chunk))
34+
}
35+
}
36+
}
37+
fetchData()
38+
}, [])
39+
}
40+
41+
export function useChat() {
42+
useStreamConnection('/demo/db-chat-api', messagesCollection)
43+
44+
const sendMessage = (message: string, user: string) => {
45+
fetch('/demo/db-chat-api', {
46+
method: 'POST',
47+
body: JSON.stringify({ text: message.trim(), user: user.trim() }),
48+
})
49+
}
50+
51+
return { sendMessage }
52+
}
53+
54+
export function useMessages() {
55+
const { data: messages } = useLiveQuery((q) =>
56+
q.from({ message: messagesCollection }).select(({ message }) => ({
57+
...message,
58+
})),
59+
)
60+
61+
return messages as Message[]
62+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { createServerFileRoute } from '@tanstack/react-start/server'
2+
3+
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
4+
import { z } from 'zod'
5+
6+
const IncomingMessageSchema = z.object({
7+
user: z.string(),
8+
text: z.string(),
9+
})
10+
11+
const MessageSchema = IncomingMessageSchema.extend({
12+
id: z.number(),
13+
})
14+
15+
export type Message = z.infer<typeof MessageSchema>
16+
17+
export const serverMessagesCollection = createCollection(
18+
localOnlyCollectionOptions({
19+
getKey: (message) => message.id,
20+
schema: MessageSchema,
21+
}),
22+
)
23+
24+
let id = 0
25+
serverMessagesCollection.insert({
26+
id: id++,
27+
user: 'Alice',
28+
text: 'Hello, how are you?',
29+
})
30+
serverMessagesCollection.insert({
31+
id: id++,
32+
user: 'Bob',
33+
text: "I'm fine, thank you!",
34+
})
35+
36+
const sendMessage = (message: { user: string; text: string }) => {
37+
serverMessagesCollection.insert({
38+
id: id++,
39+
user: message.user,
40+
text: message.text,
41+
})
42+
}
43+
44+
export const ServerRoute = createServerFileRoute('/demo/db-chat-api').methods({
45+
GET: () => {
46+
const stream = new ReadableStream({
47+
start(controller) {
48+
for (const [_id, message] of serverMessagesCollection.state) {
49+
controller.enqueue(JSON.stringify(message) + '\n')
50+
}
51+
serverMessagesCollection.subscribeChanges((changes) => {
52+
for (const change of changes) {
53+
if (change.type === 'insert') {
54+
controller.enqueue(JSON.stringify(change.value) + '\n')
55+
}
56+
}
57+
})
58+
},
59+
})
60+
61+
return new Response(stream, {
62+
headers: {
63+
'Content-Type': 'application/x-ndjson',
64+
},
65+
})
66+
},
67+
POST: async ({ request }) => {
68+
const message = IncomingMessageSchema.safeParse(await request.json())
69+
if (!message.success) {
70+
return new Response(message.error.message, { status: 400 })
71+
}
72+
sendMessage(message.data)
73+
},
74+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
3+
import ChatArea from '@/components/demo.chat-area'
4+
5+
export const Route = createFileRoute('/demo/db-chat')({
6+
component: App,
7+
})
8+
9+
function App() {
10+
return (
11+
<div className="flex flex-col h-screen bg-gray-50">
12+
<ChatArea />
13+
</div>
14+
)
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "DB",
3+
"description": "TanStack DB",
4+
"phase": "add-on",
5+
"type": "add-on",
6+
"modes": ["file-router"],
7+
"link": "https://tanstack.com/db/latest",
8+
"dependsOn": ["tanstack-query", "start"],
9+
"routes": [
10+
{
11+
"url": "/demo/db-chat",
12+
"name": "DB Chat",
13+
"path": "src/routes/demo.db-chat.tsx",
14+
"jsName": "DBChatDemo"
15+
}
16+
]
17+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"dependencies": {
3+
"@tanstack/db": "^0.1.1",
4+
"@tanstack/query-db-collection": "^0.2.0",
5+
"@tanstack/react-db": "^0.1.1",
6+
"zod": "^4.0.14"
7+
}
8+
}
Lines changed: 1 addition & 0 deletions
Loading

packages/cta-engine/src/create-app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ Use the following commands to start your app:
234234
getPackageManagerScriptCommand(options.packageManager, ['dev']),
235235
)}
236236
237-
Please read the README.md for information on testing, styling, adding routes, etc.${errorStatement}`,
237+
Please check the README.md for information on testing, styling, adding routes, etc.${errorStatement}`,
238238
)
239239
}
240240

0 commit comments

Comments
 (0)