-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
2,106 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
'use client' | ||
|
||
import { useContext, useState } from 'react' | ||
import type { MouseEvent } from 'react' | ||
import { useRouter } from 'next/navigation' | ||
import { toast } from 'sonner' | ||
|
||
import { graphql } from '@/lib/gql/generates' | ||
import { clearHomeScrollPosition } from '@/lib/stores/scroll-store' | ||
import { useMutation } from '@/lib/tabby/gql' | ||
import { | ||
AlertDialog, | ||
AlertDialogAction, | ||
AlertDialogCancel, | ||
AlertDialogContent, | ||
AlertDialogDescription, | ||
AlertDialogFooter, | ||
AlertDialogHeader, | ||
AlertDialogTitle, | ||
AlertDialogTrigger | ||
} from '@/components/ui/alert-dialog' | ||
import { Badge } from '@/components/ui/badge' | ||
import { Button, buttonVariants } from '@/components/ui/button' | ||
import { | ||
DropdownMenu, | ||
DropdownMenuContent, | ||
DropdownMenuItem, | ||
DropdownMenuTrigger | ||
} from '@/components/ui/dropdown-menu' | ||
import { | ||
IconChevronLeft, | ||
IconEdit, | ||
IconMore, | ||
IconPlus, | ||
IconSpinner, | ||
IconTrash | ||
} from '@/components/ui/icons' | ||
import { ClientOnly } from '@/components/client-only' | ||
import { NotificationBox } from '@/components/notification-box' | ||
import { ThemeToggle } from '@/components/theme-toggle' | ||
import { MyAvatar } from '@/components/user-avatar' | ||
import UserPanel from '@/components/user-panel' | ||
|
||
import { PageContext } from './page' | ||
|
||
const deleteThreadMutation = graphql(/* GraphQL */ ` | ||
mutation DeleteThread($id: ID!) { | ||
deleteThread(id: $id) | ||
} | ||
`) | ||
|
||
type HeaderProps = { | ||
threadIdFromURL?: string | ||
streamingDone?: boolean | ||
} | ||
|
||
export function Header({ threadIdFromURL, streamingDone }: HeaderProps) { | ||
const router = useRouter() | ||
const { isThreadOwner, mode, setMode } = useContext(PageContext) | ||
const isEditMode = mode === 'edit' | ||
const [deleteAlertVisible, setDeleteAlertVisible] = useState(false) | ||
const [isDeleting, setIsDeleting] = useState(false) | ||
|
||
const deleteThread = useMutation(deleteThreadMutation, { | ||
onCompleted(data) { | ||
if (data.deleteThread) { | ||
router.replace('/') | ||
} else { | ||
toast.error('Failed to delete') | ||
setIsDeleting(false) | ||
} | ||
}, | ||
onError(err) { | ||
toast.error(err?.message || 'Failed to delete') | ||
setIsDeleting(false) | ||
} | ||
}) | ||
|
||
const handleDeleteThread = (e: MouseEvent<HTMLButtonElement>) => { | ||
e.preventDefault() | ||
setIsDeleting(true) | ||
deleteThread({ | ||
id: threadIdFromURL! | ||
}) | ||
} | ||
|
||
const onNavigateToHomePage = (scroll?: boolean) => { | ||
if (scroll) { | ||
clearHomeScrollPosition() | ||
} | ||
router.push('/') | ||
} | ||
|
||
return ( | ||
<header className="flex w-full h-16 items-center justify-between px-4 lg:px-10 border-b"> | ||
<div className="flex items-center gap-x-6"> | ||
<Button | ||
variant="ghost" | ||
className="-ml-1 pl-0 text-sm text-muted-foreground" | ||
onClick={() => onNavigateToHomePage()} | ||
> | ||
<IconChevronLeft className="mr-1 h-5 w-5" /> | ||
Home | ||
</Button> | ||
</div> | ||
<div> | ||
{isEditMode ? <Badge>Editing</Badge> : <Badge>Draft Page</Badge>} | ||
</div> | ||
<div className="flex items-center gap-2"> | ||
{!isEditMode ? ( | ||
<> | ||
<DropdownMenu modal={false}> | ||
<DropdownMenuTrigger asChild> | ||
<Button size="icon" variant="ghost"> | ||
<IconMore /> | ||
</Button> | ||
</DropdownMenuTrigger> | ||
<DropdownMenuContent align="end"> | ||
{streamingDone && threadIdFromURL && ( | ||
<DropdownMenuItem | ||
className="cursor-pointer gap-2" | ||
onClick={() => onNavigateToHomePage(true)} | ||
> | ||
<IconPlus /> | ||
<span>Add new page</span> | ||
</DropdownMenuItem> | ||
)} | ||
{streamingDone && threadIdFromURL && isThreadOwner && ( | ||
<AlertDialog | ||
open={deleteAlertVisible} | ||
onOpenChange={setDeleteAlertVisible} | ||
> | ||
<AlertDialogTrigger asChild> | ||
<DropdownMenuItem className="cursor-pointer gap-2"> | ||
<IconTrash /> | ||
Delete Page | ||
</DropdownMenuItem> | ||
</AlertDialogTrigger> | ||
<AlertDialogContent> | ||
<AlertDialogHeader> | ||
<AlertDialogTitle>Delete this thread</AlertDialogTitle> | ||
<AlertDialogDescription> | ||
Are you sure you want to delete this thread? This | ||
operation is not revertible. | ||
</AlertDialogDescription> | ||
</AlertDialogHeader> | ||
<AlertDialogFooter> | ||
<AlertDialogCancel>Cancel</AlertDialogCancel> | ||
<AlertDialogAction | ||
className={buttonVariants({ variant: 'destructive' })} | ||
onClick={handleDeleteThread} | ||
> | ||
{isDeleting && ( | ||
<IconSpinner className="mr-2 h-4 w-4 animate-spin" /> | ||
)} | ||
Yes, delete it | ||
</AlertDialogAction> | ||
</AlertDialogFooter> | ||
</AlertDialogContent> | ||
</AlertDialog> | ||
)} | ||
</DropdownMenuContent> | ||
</DropdownMenu> | ||
|
||
<Button | ||
variant="ghost" | ||
className="flex items-center gap-1 px-2 font-normal" | ||
onClick={() => setMode('edit')} | ||
> | ||
<IconEdit /> | ||
Edit Page | ||
</Button> | ||
</> | ||
) : ( | ||
<> | ||
<Button onClick={e => setMode('view')}>Done</Button> | ||
</> | ||
)} | ||
<ClientOnly> | ||
<ThemeToggle /> | ||
</ClientOnly> | ||
<NotificationBox className="mr-4" /> | ||
<UserPanel | ||
showHome={false} | ||
showSetting | ||
beforeRouteChange={() => { | ||
clearHomeScrollPosition() | ||
}} | ||
> | ||
<MyAvatar className="h-10 w-10 border" /> | ||
</UserPanel> | ||
</div> | ||
</header> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { Skeleton } from '@/components/ui/skeleton' | ||
|
||
export function MessagesSkeleton() { | ||
return ( | ||
<div className="space-y-4"> | ||
<div className="space-y-2"> | ||
<Skeleton className="w-full" /> | ||
<Skeleton className="w-[70%]" /> | ||
</div> | ||
<Skeleton className="h-40 w-full" /> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
import React, { useEffect, useMemo, useRef, useState } from 'react' | ||
import { compact } from 'lodash-es' | ||
|
||
import { useDebounceCallback } from '@/lib/hooks/use-debounce' | ||
|
||
import { ConversationPair } from './page' | ||
|
||
interface Props { | ||
qaPairs: ConversationPair[] | undefined | ||
} | ||
|
||
export const Navbar = ({ qaPairs }: Props) => { | ||
const sections = useMemo(() => { | ||
if (!qaPairs?.length) return [] | ||
return compact(qaPairs.map(x => x.question)) | ||
}, [qaPairs]) | ||
|
||
const [activeNavItem, setActiveNavItem] = useState<string | undefined>() | ||
const observer = useRef<IntersectionObserver | null>(null) | ||
const updateActiveNavItem = useDebounceCallback((v: string) => { | ||
setActiveNavItem(v) | ||
}, 200) | ||
|
||
useEffect(() => { | ||
const options = { | ||
root: null, | ||
rootMargin: '70px' | ||
// threshold: 0.5, | ||
} | ||
|
||
observer.current = new IntersectionObserver(entries => { | ||
for (const entry of entries) { | ||
if (entry.isIntersecting) { | ||
updateActiveNavItem.run(entry.target.id) | ||
break | ||
} | ||
} | ||
}, options) | ||
|
||
const targets = document.querySelectorAll('.section-title') | ||
targets.forEach(target => { | ||
observer.current?.observe(target) | ||
}) | ||
|
||
return () => { | ||
observer.current?.disconnect() | ||
} | ||
}, []) | ||
|
||
return ( | ||
<nav className="sticky top-0 right-0 p-4"> | ||
<ul className="flex flex-col space-y-1"> | ||
{sections.map(section => ( | ||
<li key={section.id}> | ||
<div | ||
className={`text-sm truncate whitespace-nowrap ${ | ||
activeNavItem === section.id | ||
? 'text-foreground' | ||
: 'text-muted-foreground' | ||
}`} | ||
> | ||
{section.content} | ||
</div> | ||
</li> | ||
))} | ||
</ul> | ||
</nav> | ||
) | ||
} |
Oops, something went wrong.