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
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>
|
|
);
|
|
}
|