Skip to content

Commit ec71868

Browse files
authored
Merge pull request #9 from 100XEnginners/v2/t3chat
v3 t3chat
2 parents fc7f791 + a81a7bf commit ec71868

11 files changed

Lines changed: 490 additions & 31 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ name: Deploy to Production
33
on:
44
push:
55
branches: [ master ]
6-
workflow_dispatch:
76

87
jobs:
98
deploy:
@@ -12,25 +11,24 @@ jobs:
1211
- name: Checkout code
1312
uses: actions/checkout@v3
1413

15-
- name: Set up Node.js
16-
uses: actions/setup-node@v3
17-
with:
18-
node-version: 20
19-
2014
- name: Install dependencies
2115
run: bun install --frozen-lockfile
2216

2317
- name: Build the application
2418
run: bun run build
2519

26-
deploy:
27-
runs-on: ubuntu-latest
28-
steps:
29-
- name: Checkout repository
30-
uses: actions/checkout@v3
20+
- name: Docker login
21+
uses: docker/login-action@v2
22+
with:
23+
username: ${{ secrets.DOCKERHUB_USERNAME }}
24+
password: ${{ secrets.DOCKERHUB_TOKEN }}
3125

32-
- name: Set up Node.js
33-
uses: actions/setup-node@v3
34-
with:
35-
node-version: 20
36-
26+
- name: Build and push
27+
uses: docker/build-push-action@v4
28+
with:
29+
context: .
30+
file: ./docker/Dockerfile.frontend
31+
build-args:
32+
- DATABASE_URL=${{ secrets.DATABASE_URL }}
33+
push: true
34+
tags: t3-chat:${{ github.sha }}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 𝙏3𝙘𝒉𝙖𝒕
22

3-
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2F100xEnginners%2Ft3dotgg&env=DATABASE_URL,BETTER_AUTH_SECRET,BETTER_AUTH_URL,BETTER_AUTH_TRUSTED_ORIGINS,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_REDIRECT_URI,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET&envDescription=For%20more%20info%20on%20setting%20up%20your%20API%20keys%2C%20checkout%20the%20Readme%20below&envLink=https%3A%2F%2Fgithub.com%2F100xEnginners%2Ft3dotgg%2Fblob%2Fmain%2FREADME.md&project-name=zero-email&repository-name=zero-email&redirect-url=zero.email&demo-title=Zero&demo-description=An%20open%20source%20email%20app&demo-url=zero.email)
3+
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2F100xEnginners%2Ft3dotgg&env=DATABASE_URL,BETTER_AUTH_SECRET,BETTER_AUTH_URL,BETTER_AUTH_TRUSTED_ORIGINS,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_REDIRECT_URI,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET&envDescription=For%20more%20info%20on%20setting%20up%20your%20API%20keys%2C%20checkout%20the%20Readme%20below&envLink=https%3A%2F%2Fgithub.com%2F100xEnginners%2Ft3dotgg%2Fblob%2Fmain%2FREADME.md&project-name=t3chat&repository-name=t3chat&redirect-url=t3chat&demo-title=T3chat&demo-description=An%20open%20source%20t3chat%20clone&demo-url=t3chat.xyz)
44

55
An Open-Source Version of t3.chat.
66

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"use client";
2+
import SharedChat from "@/app/_components/SharedChat";
3+
import { useParams } from "next/navigation";
4+
5+
export default function SingleChatPage() {
6+
const { chatId } = useParams();
7+
console.log(chatId)
8+
return (
9+
<>
10+
<SharedChat chatId={chatId as string} />
11+
</>
12+
);
13+
}

src/app/_components/SharedChat.tsx

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
"use client";
2+
import React, { useState, useRef, useEffect } from "react";
3+
import {
4+
CopyIcon,
5+
ThumbsDownIcon,
6+
ThumbsUpIcon,
7+
SpeakerHighIcon,
8+
SpeakerXIcon,
9+
CheckIcon,
10+
CheckCircleIcon,
11+
} from "@phosphor-icons/react";
12+
import ReactMarkdown from "react-markdown";
13+
import SyntaxHighlighter from "react-syntax-highlighter";
14+
import remarkGfm from "remark-gfm";
15+
import { Geist_Mono } from "next/font/google";
16+
import { cn } from "@/lib/utils";
17+
import { useSpeechSynthesis } from "react-speech-kit";
18+
import { useTheme } from "next-themes";
19+
import { Loader2Icon, WrapText } from "lucide-react";
20+
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
21+
import { api } from "@/trpc/react";
22+
23+
const geistMono = Geist_Mono({
24+
subsets: ["latin"],
25+
variable: "--font-mono",
26+
preload: true,
27+
display: "swap",
28+
});
29+
30+
interface ChatMessage {
31+
id: string;
32+
role: "user" | "assistant";
33+
content: string;
34+
}
35+
36+
37+
const SharedChat = ({ chatId: initialChatId }: { chatId: string }) => {
38+
const [messages, setMessages] = useState<ChatMessage[]>([]);
39+
const [copied, setCopied] = useState(false);
40+
const messagesEndRef = useRef<HTMLDivElement>(null);
41+
const { resolvedTheme } = useTheme();
42+
const [chatId, setChatId] = useState<string>(initialChatId);
43+
44+
const {data: chatMessages} = api.chat.getChatById.useQuery({
45+
chatId: chatId,
46+
});
47+
48+
useEffect(() => {
49+
setChatId(initialChatId);
50+
}, [initialChatId]);
51+
52+
useEffect(() => {
53+
if (chatMessages) {
54+
setMessages(chatMessages.messages.map((message) => ({
55+
id: message.id,
56+
role: message.role === "USER" ? "user" : "assistant",
57+
content: message.content,
58+
})));
59+
}
60+
}, [chatMessages]);
61+
62+
63+
64+
const {
65+
speak,
66+
cancel,
67+
speaking,
68+
supported: ttsSupported,
69+
voices,
70+
} = useSpeechSynthesis();
71+
const [selectedVoice, setSelectedVoice] =
72+
useState<SpeechSynthesisVoice | null>(null);
73+
74+
75+
76+
useEffect(() => {
77+
if (ttsSupported && voices.length > 0) {
78+
const defaultVoice = voices.find((v) => v.default) || voices[0];
79+
setSelectedVoice(defaultVoice!);
80+
}
81+
}, [voices, ttsSupported]);
82+
83+
84+
85+
const scrollToBottom = () => {
86+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
87+
};
88+
89+
useEffect(() => {
90+
scrollToBottom();
91+
}, [messages]);
92+
93+
94+
95+
96+
const handleCopy = async (content: string) => {
97+
try {
98+
await navigator.clipboard.writeText(content);
99+
setCopied(true);
100+
setTimeout(() => setCopied(false), 2000); // Reset after 2s
101+
} catch (err) {
102+
console.error("Failed to copy: ", err);
103+
}
104+
};
105+
106+
107+
108+
return (
109+
<div className="h-[96vh] w-full">
110+
<div className="relative flex h-full w-full flex-col">
111+
<div className="no-scrollbar flex flex-1 flex-col overflow-y-auto px-4 pb-40 md:px-4">
112+
<div className="mx-auto w-full max-w-4xl py-4">
113+
{messages.length === 0 ? (
114+
<div className="text-muted-foreground flex h-[50vh] items-center justify-center">
115+
<Loader2Icon className="animate-spin" />
116+
</div>
117+
) : (
118+
<div className="no-scrollbar mt-6 flex h-full w-full flex-1 flex-col gap-4 overflow-y-auto px-4 pt-4 pb-10 md:px-8">
119+
<div className="mx-auto h-full w-full max-w-4xl">
120+
{messages.map((message) => (
121+
<div
122+
key={message.id}
123+
className={`group mb-8 flex w-full flex-col ${message.role === "assistant" ? "items-start" : "items-end"} gap-2`}
124+
>
125+
<div
126+
className={cn(
127+
"prose dark:prose-invert max-w-none rounded-lg px-4 py-2",
128+
message.role === "user"
129+
? "bg-accent/40 w-fit max-w-full font-medium"
130+
: "w-full p-0",
131+
)}
132+
>
133+
<ReactMarkdown
134+
remarkPlugins={[remarkGfm]}
135+
components={{
136+
code(props) {
137+
const { children, className, ...rest } = props;
138+
const match = /language-(\w+)/.exec(
139+
className ?? "",
140+
);
141+
const isInline = !match;
142+
const codeContent = Array.isArray(children)
143+
? children.join("")
144+
: typeof children === "string"
145+
? children
146+
: "";
147+
148+
return isInline ? (
149+
<code
150+
className={cn(
151+
"bg-accent rounded-sm px-1 py-0.5 text-sm",
152+
geistMono.className,
153+
)}
154+
{...rest}
155+
>
156+
{children}
157+
</code>
158+
) : (
159+
<div
160+
className={`${geistMono.className} my-4 overflow-hidden rounded-md`}
161+
>
162+
<div className="bg-accent flex items-center justify-between px-4 py-2 text-sm">
163+
<div>{match ? match[1] : "text"}</div>
164+
<div className="flex items-center gap-2">
165+
<button
166+
className={`hover:bg-muted/40 flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-all duration-200`}
167+
aria-label="Toggle line wrapping"
168+
>
169+
<WrapText className="h-3 w-3" />
170+
</button>
171+
<button
172+
onClick={() => handleCopy(codeContent)}
173+
className={`hover:bg-muted/40 sticky top-10 flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-all duration-200`}
174+
aria-label="Copy code"
175+
>
176+
{copied ? (
177+
<>
178+
<CheckCircleIcon
179+
weight="bold"
180+
className="size-4"
181+
/>
182+
</>
183+
) : (
184+
<>
185+
<CopyIcon className="size-4" />
186+
</>
187+
)}
188+
</button>
189+
</div>
190+
</div>
191+
<SyntaxHighlighter
192+
language={match ? match[1] : "text"}
193+
style={atomOneDark}
194+
customStyle={{
195+
margin: 0,
196+
padding: "1rem",
197+
backgroundColor:
198+
resolvedTheme === "dark"
199+
? "#1a1620"
200+
: "#f5ecf9",
201+
color:
202+
resolvedTheme === "dark"
203+
? "#e5e5e5"
204+
: "#171717",
205+
borderRadius: 0,
206+
borderBottomLeftRadius: "0.375rem",
207+
borderBottomRightRadius: "0.375rem",
208+
fontSize: "1.2rem",
209+
fontFamily: `var(--font-geist-mono), ${geistMono.style.fontFamily}`,
210+
}}
211+
codeTagProps={{
212+
style: {
213+
fontFamily: `var(--font-geist-mono), ${geistMono.style.fontFamily}`,
214+
fontSize: "0.85em",
215+
whiteSpace: "pre-wrap",
216+
overflowWrap: "break-word",
217+
wordBreak: "break-word",
218+
},
219+
}}
220+
PreTag="div"
221+
>
222+
{codeContent}
223+
</SyntaxHighlighter>
224+
</div>
225+
);
226+
},
227+
strong: (props) => (
228+
<span className="font-bold">
229+
{props.children}
230+
</span>
231+
),
232+
a: (props) => (
233+
<a
234+
className="text-primary underline"
235+
href={props.href}
236+
>
237+
{props.children}
238+
</a>
239+
),
240+
h1: (props) => (
241+
<h1 className="my-4 text-2xl font-bold">
242+
{props.children}
243+
</h1>
244+
),
245+
h2: (props) => (
246+
<h2 className="my-3 text-xl font-bold">
247+
{props.children}
248+
</h2>
249+
),
250+
h3: (props) => (
251+
<h3 className="my-2 text-lg font-bold">
252+
{props.children}
253+
</h3>
254+
),
255+
}}
256+
>
257+
{message.content}
258+
</ReactMarkdown>
259+
</div>
260+
<div className="font-medium">
261+
{message.role === "assistant" && (
262+
<div className="invisible flex w-fit items-center gap-2 text-base font-semibold group-hover:visible">
263+
<button className="hover:bg-accent flex size-7 items-center justify-center rounded-lg">
264+
<ThumbsUpIcon weight="bold" />
265+
</button>
266+
<button className="hover:bg-accent flex size-7 items-center justify-center rounded-lg">
267+
<ThumbsDownIcon weight="bold" />
268+
</button>
269+
<button
270+
onClick={() => handleCopy(message.content)}
271+
className="hover:bg-accent flex size-7 items-center justify-center rounded-lg"
272+
>
273+
{!copied ? (
274+
<CopyIcon weight="bold" />
275+
) : (
276+
<CheckIcon weight="bold" />
277+
)}
278+
</button>
279+
<button
280+
className="hover:bg-accent flex size-7 items-center justify-center rounded-lg"
281+
onClick={() => {
282+
if (speaking) {
283+
cancel();
284+
} else if (ttsSupported && selectedVoice) {
285+
speak({
286+
text: message.content,
287+
voice: selectedVoice,
288+
});
289+
}
290+
}}
291+
>
292+
{speaking ? (
293+
<SpeakerXIcon weight="bold" />
294+
) : (
295+
<SpeakerHighIcon weight="bold" />
296+
)}
297+
</button>
298+
</div>
299+
)}
300+
{message.role === "user" && (
301+
<button
302+
onClick={() => handleCopy(message.content)}
303+
className="hover:bg-accent flex size-7 items-center justify-center rounded-lg"
304+
>
305+
{!copied ? (
306+
<CopyIcon weight="bold" />
307+
) : (
308+
<CheckIcon weight="bold" />
309+
)}
310+
</button>
311+
)}
312+
</div>
313+
</div>
314+
))}
315+
<div ref={messagesEndRef} />
316+
</div>
317+
</div>
318+
)}
319+
<div ref={messagesEndRef} />
320+
</div>
321+
</div>
322+
</div>
323+
</div>
324+
);
325+
};
326+
327+
export default SharedChat;

0 commit comments

Comments
 (0)