Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/docs/src/app/docs/(default)/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
PageLastUpdate,
} from '@/components/layout/notebook/page';
import { TechArticleSchema, BreadcrumbSchema } from '@/components/structured-data';
import { PageFeedback } from '@/components/page-feedback';

interface PageParams {
slug?: string[];
Expand Down Expand Up @@ -68,6 +69,7 @@ export default async function Page({
/>
)}
</div>
<PageFeedback />
</DocsPage>
</>
);
Expand Down
22 changes: 13 additions & 9 deletions apps/docs/src/app/docs/(default)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { VersionSwitcher } from '@/components/version-switcher';
import type { LinkItemType } from 'fumadocs-ui/layouts/shared';
import { DocsLayout } from '@/components/layout/notebook';
import { LATEST_VERSION } from '@/lib/version';
import { FloatingAsk } from '@/components/floating-ask';

export default async function Layout({
children,
Expand All @@ -21,14 +22,17 @@ export default async function Layout({
];

return (
<DocsLayout
{...base}
links={navbarLinks}
nav={{ ...nav }}
sidebar={{ collapsible: false }}
tree={source.pageTree}
>
{children}
</DocsLayout>
<>
<DocsLayout
{...base}
links={navbarLinks}
nav={{ ...nav }}
sidebar={{ collapsible: false }}
tree={source.pageTree}
>
{children}
</DocsLayout>
<FloatingAsk />
</>
);
}
78 changes: 78 additions & 0 deletions apps/docs/src/components/floating-ask.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use client';

import { useEffect, useCallback, useState } from 'react';
import { ArrowUp } from 'lucide-react';
import { cn } from '@prisma-docs/ui/lib/cn';
import { useAIChatContext } from '@/hooks/use-ai-chat';

export function FloatingAsk() {
const { setIsOpen, setPrompt } = useAIChatContext();
const [visible, setVisible] = useState(true);
const [inputValue, setInputValue] = useState('');
const [isMac, setIsMac] = useState(true);

useEffect(() => {
setIsMac(navigator.platform?.toUpperCase().includes('MAC') ?? true);
}, []);

const checkScroll = useCallback(() => {
const distanceFromBottom =
document.documentElement.scrollHeight -
window.scrollY -
window.innerHeight;
setVisible(distanceFromBottom > 200);
}, []);

useEffect(() => {
window.addEventListener('scroll', checkScroll, { passive: true });
checkScroll();
return () => window.removeEventListener('scroll', checkScroll);
}, [checkScroll]);

const handleSubmit = () => {
if (inputValue.trim()) {
setPrompt(inputValue.trim());
}
setIsOpen(true);
setInputValue('');
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};

return (
<div
className={cn(
'fixed bottom-6 left-1/2 z-40 -translate-x-1/2 w-full max-w-lg px-4 transition-opacity duration-500 ease-in-out max-md:hidden',
visible ? 'opacity-100' : 'opacity-0 pointer-events-none',
)}
>
<div className="group flex flex-col gap-2 rounded-2xl border border-fd-foreground/20 bg-fd-background/80 px-4 pt-3 pb-3 shadow-lg backdrop-blur-xl transition-transform duration-200 ease-out hover:scale-[1.025]">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask a question..."
className="w-full bg-transparent text-sm text-fd-foreground placeholder:text-fd-muted-foreground focus:outline-none"
/>
<div className="flex items-center justify-end gap-2">
<kbd className="hidden sm:inline-flex items-center gap-0.5 rounded-md border border-fd-border bg-fd-muted px-1.5 py-0.5 text-[10px] font-medium text-fd-muted-foreground">
{isMac ? '⌘' : 'Ctrl'}I
</kbd>
<button
type="button"
onClick={handleSubmit}
className="flex size-7 items-center justify-center rounded-full bg-fd-primary text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
<ArrowUp className="size-4" />
</button>
</div>
</div>
</div>
);
}
4 changes: 4 additions & 0 deletions apps/docs/src/components/layout/notebook/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type SidebarTabWithProps,
} from '../sidebar/tabs/dropdown';
import { AIChatSidebar } from '@/components/ai-chat-sidebar';
import { StatusIndicator } from '@/components/status-indicator';

export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
Expand Down Expand Up @@ -310,6 +311,9 @@ function DocsNavbar({
/>
))}
<div className="flex flex-1 items-center justify-end gap-2">
<div className="max-md:hidden">
<StatusIndicator />
</div>
<AIChatSidebar />
<div className="flex items-center gap-2 max-md:hidden">
{links
Expand Down
122 changes: 122 additions & 0 deletions apps/docs/src/components/page-feedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use client';

import { useState, useEffect, useCallback } from 'react';
import { ThumbsUp, ThumbsDown, Send } from 'lucide-react';
import { cn } from '@prisma-docs/ui/lib/cn';
import { usePathname } from 'fumadocs-core/framework';
import posthog from 'posthog-js';

type FeedbackState = 'idle' | 'upvoted' | 'downvoted' | 'submitted';

function getStorageKey(path: string) {
return `prisma-docs:page-feedback:${path}`;
}

export function PageFeedback() {
const pathname = usePathname();
const [state, setState] = useState<FeedbackState>('idle');
const [comment, setComment] = useState('');
const [showTextarea, setShowTextarea] = useState(false);

useEffect(() => {
try {
const stored = localStorage.getItem(getStorageKey(pathname));
if (stored) setState('submitted');
} catch {}
}, [pathname]);

const persist = useCallback(
(vote: 'up' | 'down', text?: string) => {
try {
localStorage.setItem(getStorageKey(pathname), JSON.stringify({ vote, text }));
} catch {}
posthog.capture('docs:page_feedback', {
path: pathname,
vote,
comment: text ?? null,
});
},
[pathname],
);

const handleUp = () => {
setState('submitted');
persist('up');
};

const handleDown = () => {
setState('downvoted');
setShowTextarea(true);
};

const handleSubmitComment = () => {
setState('submitted');
persist('down', comment || undefined);
};

if (state === 'submitted') {
return (
<div className="flex items-center gap-2 rounded-lg border border-fd-border bg-fd-card px-4 py-3 text-sm text-fd-muted-foreground">
<span>Thanks for your feedback!</span>
</div>
);
}

return (
<div className="flex flex-col gap-3 rounded-lg border border-fd-border bg-fd-card px-4 py-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-fd-foreground">
Was this page helpful?
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={handleUp}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-fd-border px-3 py-1.5 text-sm transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground',
'text-fd-muted-foreground',
)}
>
<ThumbsUp className="size-3.5" />
Yes
</button>
<button
type="button"
onClick={handleDown}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-fd-border px-3 py-1.5 text-sm transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground',
showTextarea
? 'bg-fd-accent text-fd-accent-foreground'
: 'text-fd-muted-foreground',
)}
>
<ThumbsDown className="size-3.5" />
No
</button>
</div>
</div>

{showTextarea && (
<div className="flex flex-col gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="What was missing or unclear? (optional)"
rows={3}
className="w-full resize-none rounded-md border border-fd-border bg-fd-background px-3 py-2 text-sm text-fd-foreground placeholder:text-fd-muted-foreground focus:outline-none focus:ring-2 focus:ring-fd-ring"
/>
<div className="flex justify-end">
<button
type="button"
onClick={handleSubmitComment}
className="inline-flex items-center gap-1.5 rounded-md bg-fd-primary px-3 py-1.5 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
<Send className="size-3.5" />
Send feedback
</button>
</div>
</div>
)}
</div>
);
}
54 changes: 50 additions & 4 deletions apps/docs/src/components/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {
SearchDialogOverlay,
type SharedProps,
} from 'fumadocs-ui/components/dialog/search';
import { SearchIcon, X } from 'lucide-react';
import { ComponentProps, useEffect, useRef } from 'react';
import { SearchIcon, SearchX, X } from 'lucide-react';
import { type ComponentProps, useCallback, useEffect, useRef } from 'react';
import posthog from 'posthog-js';

export function CustomSearchDialogIcon(
Expand All @@ -38,7 +38,9 @@ export default function CustomSearchDialog(props: SharedProps) {

const lastCapturedQueryRef = useRef<string | null>(null);
const stabilityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const clickedResultRef = useRef(false);
const lastNoResultQueryRef = useRef<string | null>(null);

useEffect(() => {
if (
search.length > 0 &&
Expand All @@ -62,12 +64,42 @@ export default function CustomSearchDialog(props: SharedProps) {
};
}, [query.data, query.isLoading, search]);

const resultsArray = query.data !== 'empty' && query.data !== undefined ? query.data : null;
const hasResults = Array.isArray(resultsArray) && resultsArray.length > 0;
const showNoResults = !query.isLoading && search.length > 0 && !hasResults;

useEffect(() => {
if (showNoResults && lastNoResultQueryRef.current !== search) {
lastNoResultQueryRef.current = search;
posthog.capture('docs:search_no_results', { query: search });
}
}, [showNoResults, search]);

const handleOpenChange = useCallback(
(open: boolean) => {
if (!open && search.length > 0 && !clickedResultRef.current) {
const resultCount = Array.isArray(resultsArray) ? resultsArray.length : 0;
posthog.capture('docs:search_no_click', {
query: search,
result_count: resultCount,
});
}
if (!open) {
clickedResultRef.current = false;
lastNoResultQueryRef.current = null;
}
props.onOpenChange?.(open);
},
[search, resultsArray, props],
);

return (
<SearchDialog
search={search}
onSearchChange={setSearch}
isLoading={query.isLoading}
{...props}
onOpenChange={handleOpenChange}
>
<SearchDialogOverlay suppressHydrationWarning />
<SearchDialogContent>
Expand All @@ -78,7 +110,21 @@ export default function CustomSearchDialog(props: SharedProps) {
<X className="size-4" aria-hidden="true" />
</SearchDialogClose>
</SearchDialogHeader>
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={() => { clickedResultRef.current = true; }}>
<SearchDialogList items={resultsArray} />
</div>
{showNoResults && (
<div className="flex flex-col items-center gap-2 px-6 py-8 text-center">
<SearchX className="size-10 text-fd-muted-foreground/50" />
<p className="text-sm font-medium text-fd-muted-foreground">
No results for &ldquo;{search}&rdquo;
</p>
<p className="text-xs text-fd-muted-foreground/70">
Try different keywords or check for typos
</p>
</div>
)}
</SearchDialogContent>
</SearchDialog>
);
Expand Down
Loading
Loading