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.

330 lines
9.7 KiB
TypeScript

import "@mantine/core/styles.css";
import { navigate } from "vike/client/router";
import {
AppShell,
Burger,
Group,
Image,
MantineProvider,
NavLink,
Title,
} from "@mantine/core";
import {
IconHome2,
IconChevronRight,
IconActivity,
IconTrash,
IconCircle,
IconCircleFilled,
IconTrashFilled,
IconPlus,
} from "@tabler/icons-react";
import { useDisclosure } from "@mantine/hooks";
import theme from "./theme.js";
import logoUrl from "../assets/logo.png";
import { 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 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>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<NavLink href="/" label="Welcome" active={urlPathname === "/"} />
<NavLinkChat key="chat-new" />
</AppShell.Navbar>
<AppShell.Main> {children} </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>
);
}