You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

412 lines
12 KiB
TypeScript

import "@mantine/core/styles.css";
import { navigate } from "vike/client/router";
import {
AppShell,
Box,
Burger,
Button,
Group,
Image,
MantineProvider,
NavLink,
ScrollArea,
Title,
} from "@mantine/core";
import {
IconHome2,
IconChevronRight,
IconActivity,
IconTrash,
IconCircle,
IconCircleFilled,
IconTrashFilled,
IconPlus,
IconBrandGoogle,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import theme from "./theme.js";
import logoUrl from "../assets/logo.png";
import { useEffect, useState } from "react";
import { TRPCProvider, useTRPC } from "../trpc/client.js";
import { usePageContext } from "vike-react/usePageContext";
import "./hover.css";
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createTRPCClient,
httpBatchLink,
httpSubscriptionLink,
splitLink,
} from "@trpc/client";
import type { AppRouter } from "../trpc/router.js";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (typeof window === "undefined") {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
export function SignInWithGoogle() {
const pageContext = usePageContext();
/** This is populated using the +onCreatePageContext.server.ts hook */
const user = pageContext?.user;
const [csrfToken, setCsrfToken] = useState("");
useEffect(() => {
fetch("/api/auth/csrf")
.then((res) => res.json())
.then((obj) => setCsrfToken(obj.csrfToken));
}, []);
if (user?.id) {
return (
<form
action="/api/auth/signout"
method="post"
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<input type="hidden" name="redirectTo" value={"/"} />
<input type="hidden" name="csrfToken" value={csrfToken} />
<span>Signed in as {user?.email}</span>
<Button
type="submit"
leftSection={<IconBrandGoogle />}
hidden={typeof user?.id === "string"}
>
Sign Out
</Button>
</form>
);
}
return (
<form
action="/api/auth/signin/google"
method="post"
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-md shadow-sm bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<input type="hidden" name="redirectTo" value={"/"} />
<input type="hidden" name="csrfToken" value={csrfToken} />
<Button
type="submit"
leftSection={<IconBrandGoogle />}
hidden={typeof user?.id === "string"}
>
Sign in with Google
</Button>
</form>
);
}
export function SignOutButton() {
const handleSignOut = () => {
window.location.href = "/api/auth/signout";
};
return (
<button
type="button"
onClick={handleSignOut}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Sign Out
</button>
);
}
export default function LayoutDefault({
children,
}: {
children: React.ReactNode;
}) {
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const [opened, { toggle }] = useDisclosure();
const queryClient = getQueryClient();
const [trpc] = useState(() =>
createTRPCClient<AppRouter>({
links: [
splitLink({
// uses the httpSubscriptionLink for subscriptions
condition: (op) => op.type === "subscription",
true: httpSubscriptionLink({
url: "/api/trpc",
}),
false: httpBatchLink({
url: "/api/trpc",
methodOverride: "POST",
}),
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpc} queryClient={queryClient}>
<MantineProvider theme={theme}>
<AppShell
header={{ height: 80 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !opened },
}}
padding="lg"
>
<AppShell.Header>
<Group px="md" wrap="nowrap">
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
/>
<a href="/">
{" "}
<Image h={50} fit="contain" src={logoUrl} />{" "}
</a>
<Title
textWrap="balance"
lineClamp={1}
styles={{
root: {
lineHeight: theme?.lineHeights?.["6xl"],
},
}}
>
Token-Efficient Context Engineering
</Title>
<SignInWithGoogle />
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<NavLink href="/" label="Welcome" active={urlPathname === "/"} />
<NavLinkChat key="chat-new" />
</AppShell.Navbar>
<AppShell.Main
styles={{
main: {
height: "100vh",
overflow: "hidden",
},
}}
>
<Box h="100%" w="100%">
{" "}
{children}{" "}
</Box>
</AppShell.Main>
</AppShell>
</MantineProvider>
</TRPCProvider>
</QueryClientProvider>
);
}
function NavLinkChat() {
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const trpc = useTRPC();
const queryClient = useQueryClient();
// const
const startConversation = useMutation(
trpc.chat.conversations.start.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.chat.conversations.fetchAll.queryKey(),
});
},
})
);
const deleteConversation = useMutation(
trpc.chat.conversations.deleteOne.mutationOptions({
onMutate: async ({ id: conversationIdToDelete }) => {
/** Cancel affected queries that may be in-flight: */
await queryClient.cancelQueries({
queryKey: trpc.chat.conversations.fetchAll.queryKey(),
});
/** Optimistically update the affected queries in react-query's cache: */
const previousConversations = await queryClient.getQueryData(
trpc.chat.conversations.fetchAll.queryKey()
);
if (!previousConversations) {
return {
previousConversations: [],
newConversations: [],
};
}
const newConversations = previousConversations.filter(
(c) => c.id !== conversationIdToDelete
);
queryClient.setQueryData(
trpc.chat.conversations.fetchAll.queryKey(),
newConversations
);
return { previousConversations, newConversations };
},
onSettled: async (data, variables, context) => {
await queryClient.invalidateQueries({
queryKey: trpc.chat.conversations.fetchAll.queryKey(),
});
},
onError: async (error, variables, context) => {
console.error(error);
if (!context) return;
queryClient.setQueryData(
trpc.chat.conversations.fetchAll.queryKey(),
context.previousConversations
);
},
})
);
const { data: conversations } = useQuery(
trpc.chat.conversations.fetchAll.queryOptions()
);
// const selectedConversationId = useStore(
// (state) => state.selectedConversationId
// );
const selectedConversationId = urlPathname.split("/chat/")[1];
async function handleDeleteConversation(conversationIdToDelete: string) {
await deleteConversation.mutateAsync(
{ id: conversationIdToDelete },
{
onSuccess: async (x, y, { newConversations }) => {
/** If the selected conversation was deleted, navigate/select a
* different conversation (creating a new one if necessary): */
if (conversationIdToDelete === selectedConversationId) {
if (newConversations && newConversations.length > 0) {
// Navigate to the first conversation
const lastConversation =
newConversations[newConversations.length - 1];
await navigate(`/chat/${lastConversation.id}`);
} else {
// No conversations left, create a new one
const newConversation = await startConversation.mutateAsync();
if (!newConversation?.id) return;
await navigate(`/chat/${newConversation.id}`);
}
}
},
}
);
}
return (
<NavLink
key="chat-new"
href="#required-for-focus-management"
label={
<Group justify="space-between">
<span>Chats</span>
<IconPlus
size={16}
stroke={1.5}
className="border-on-hover"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startConversation.mutateAsync().then((res) => {
if (!res?.id) return;
// addConversation(res);
navigate(`/chat/${res.id}`);
});
}}
/>
</Group>
}
leftSection={<IconActivity size={16} stroke={1.5} />}
rightSection={
<IconChevronRight
size={12}
stroke={1.5}
className="mantine-rotate-rtl"
/>
}
variant="subtle"
active={urlPathname.startsWith("/chat")}
defaultOpened={true}
>
{conversations?.map((conversation) => (
<NavLink
key={conversation.id}
href={`/chat/${conversation.id}`}
label={conversation.title}
className="hover-container"
leftSection={
<>
<IconCircle
size={16}
stroke={1.5}
className={`show-by-default ${
selectedConversationId === conversation.id ? "none" : ""
}`}
/>
<IconCircleFilled
size={16}
stroke={1.5}
className={`show-on-hover ${
selectedConversationId === conversation.id ? "block" : ""
}`}
/>
</>
}
rightSection={
<>
<IconTrash
size={16}
stroke={1.5}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteConversation(conversation.id);
}}
className="show-by-default"
/>
<IconTrashFilled
size={16}
stroke={1.5}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteConversation(conversation.id);
}}
className="show-on-hover border-on-hover"
/>
</>
}
variant="subtle"
active={conversation.id === selectedConversationId}
/>
))}
</NavLink>
);
}