Skip to content

Commit 9e871bc

Browse files
committed
Third iteration
1 parent 80cb951 commit 9e871bc

File tree

11 files changed

+551
-209
lines changed

11 files changed

+551
-209
lines changed
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
'use client';
2+
import { tcls } from '@/lib/tailwind';
3+
import { Icon, type IconName } from '@gitbook/icons';
4+
import { AnimatePresence, motion } from 'framer-motion';
5+
import Link from 'next/link';
6+
import { useEffect, useState } from 'react';
7+
import { useVisitedPages } from '../Insights';
8+
import { usePageContext } from '../PageContext';
9+
import { Emoji } from '../primitives';
10+
import { type SuggestedPage, useAdaptiveContext } from './AdaptiveContext';
11+
import { streamNextPageSuggestions } from './server-actions/streamNextPageSuggestions';
12+
13+
export function AINextPageSuggestions() {
14+
const { selectedJourney, open } = useAdaptiveContext();
15+
16+
const currentPage = usePageContext();
17+
const visitedPages = useVisitedPages((state) => state.pages);
18+
19+
const [pages, setPages] = useState<SuggestedPage[]>(
20+
selectedJourney?.pages ?? Array.from({ length: 5 })
21+
);
22+
23+
useEffect(() => {
24+
let canceled = false;
25+
26+
if (selectedJourney?.pages && selectedJourney.pages.length > 0) {
27+
setPages(selectedJourney.pages);
28+
}
29+
30+
(async () => {
31+
const stream = await streamNextPageSuggestions({
32+
currentPage: {
33+
id: currentPage.pageId,
34+
title: currentPage.title,
35+
},
36+
currentSpace: {
37+
id: currentPage.spaceId,
38+
},
39+
visitedPages: visitedPages,
40+
});
41+
42+
for await (const page of stream) {
43+
if (canceled) return;
44+
45+
setPages((prev) => {
46+
const newPages = [...prev];
47+
const emptyIndex = newPages.findIndex((j) => !j?.id);
48+
if (emptyIndex >= 0) {
49+
newPages[emptyIndex] = page;
50+
}
51+
return newPages;
52+
});
53+
}
54+
})();
55+
56+
return () => {
57+
canceled = true;
58+
};
59+
}, [selectedJourney, currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages]);
60+
61+
return (
62+
<AnimatePresence initial={false}>
63+
{open && (
64+
<motion.div
65+
key="next-page-suggestions"
66+
initial={{ opacity: 0, height: 0 }}
67+
animate={{ opacity: 1, height: 'auto' }}
68+
exit={{ opacity: 0, height: 0 }}
69+
>
70+
<div className="relative mb-2 flex flex-row items-center gap-3">
71+
{selectedJourney?.icon ? (
72+
<Icon
73+
key={selectedJourney.icon}
74+
icon={selectedJourney.icon as IconName}
75+
className="absolute left-0 size-6 animate-scaleIn text-tint-subtle [animation-delay:100ms]"
76+
/>
77+
) : null}
78+
<div
79+
className={tcls(
80+
'flex flex-col transition-all',
81+
selectedJourney?.icon ? 'ml-9' : 'delay-0'
82+
)}
83+
>
84+
<div className="flex flex-row items-center gap-2 font-semibold text-tint text-xs uppercase tracking-wide">
85+
Suggested pages
86+
</div>
87+
{selectedJourney?.label ? (
88+
<h5
89+
key={selectedJourney.label}
90+
className="animate-fadeIn font-semibold text-base"
91+
>
92+
{selectedJourney.label}
93+
</h5>
94+
) : null}
95+
</div>
96+
</div>
97+
<div className="-mb-1.5 flex flex-col gap-1">
98+
{pages.map((page, index) =>
99+
page?.id ? (
100+
<Link
101+
key={selectedJourney?.label + page.id}
102+
className="-mx-2 flex animate-fadeIn gap-2 rounded px-2.5 py-1 transition-all hover:bg-tint-hover hover:text-tint-strong"
103+
href={page.href}
104+
style={{ animationDelay: `${0.2 + index * 0.05}s` }}
105+
>
106+
{page.icon ? (
107+
<Icon
108+
icon={page.icon as IconName}
109+
className="mt-0.5 size-4 text-tint-subtle"
110+
/>
111+
) : null}
112+
{page.emoji ? <Emoji code={page.emoji} /> : null}
113+
{page.title}
114+
</Link>
115+
) : (
116+
<div
117+
key={index}
118+
className="my-1 h-5 animate-pulse rounded bg-tint-hover"
119+
style={{ animationDelay: `${index * 0.2}s`, width: `${(((index * 17) % 50) + 50)}%` }}
120+
/>
121+
)
122+
)}
123+
</div>
124+
</motion.div>
125+
)}
126+
</AnimatePresence>
127+
);
128+
}
Lines changed: 57 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,146 +1,66 @@
11
'use client';
22
import { tcls } from '@/lib/tailwind';
33
import { Icon, type IconName } from '@gitbook/icons';
4-
import Link from 'next/link';
5-
import { useEffect } from 'react';
6-
import { useState } from 'react';
7-
import { useVisitedPages } from '../Insights';
8-
import { usePageContext } from '../PageContext';
9-
import { streamPageJourneySuggestions } from './server-actions';
4+
import { AnimatePresence, motion } from 'framer-motion';
5+
import { useAdaptiveContext } from './AdaptiveContext';
106

11-
const JOURNEY_COUNT = 4;
12-
13-
export function AIPageJourneySuggestions(props: { spaces: { id: string; title: string }[] }) {
14-
const { spaces } = props;
15-
16-
const currentPage = usePageContext();
17-
18-
// const language = useLanguage();
19-
const visitedPages = useVisitedPages((state) => state.pages);
20-
const [journeys, setJourneys] = useState<
21-
Array<{
22-
label: string;
23-
icon?: string;
24-
pages?: Array<{
25-
id: string;
26-
title: string;
27-
href: string;
28-
icon?: string;
29-
emoji?: string;
30-
}>;
31-
}>
32-
>(Array.from({ length: JOURNEY_COUNT }));
33-
const [selected, setSelected] = useState<
34-
| {
35-
label: string;
36-
icon?: string;
37-
pages?: Array<{
38-
id: string;
39-
title: string;
40-
href: string;
41-
icon?: string;
42-
emoji?: string;
43-
}>;
44-
}
45-
| undefined
46-
>();
47-
48-
useEffect(() => {
49-
let canceled = false;
50-
51-
(async () => {
52-
const stream = await streamPageJourneySuggestions({
53-
count: JOURNEY_COUNT,
54-
currentPage: {
55-
id: currentPage.pageId,
56-
title: currentPage.title,
57-
},
58-
currentSpace: {
59-
id: currentPage.spaceId,
60-
},
61-
allSpaces: spaces,
62-
visitedPages,
63-
});
64-
65-
for await (const journey of stream) {
66-
if (canceled) return;
67-
68-
// Find the first empty slot in the journeys array
69-
setJourneys((prev) => {
70-
const newJourneys = [...prev];
71-
const emptyIndex = newJourneys.findIndex((j) => !j?.label);
72-
if (emptyIndex >= 0) {
73-
newJourneys[emptyIndex] = journey;
74-
}
75-
return newJourneys;
76-
});
77-
}
78-
})();
79-
80-
return () => {
81-
canceled = true;
82-
};
83-
}, [currentPage.pageId, currentPage.spaceId, currentPage.title, visitedPages, spaces]);
7+
export function AIPageJourneySuggestions() {
8+
const { journeys, selectedJourney, setSelectedJourney, open } = useAdaptiveContext();
849

8510
return (
86-
<div>
87-
<div className="grid w-72 grid-cols-2 gap-2 text-sm">
88-
{journeys.map((journey, i) => (
89-
<button
90-
type="button"
91-
key={i}
92-
disabled={journey?.label === undefined}
93-
className={tcls(
94-
'flex flex-col items-center justify-center gap-2 rounded border border-tint-subtle px-2 py-4 text-center transition-all duration-500 *:animate-fadeIn *:delay-200',
95-
journey?.label === undefined
96-
? 'h-24 scale-90 animate-pulse'
97-
: 'duration-300 hover:border-tint hover:bg-tint-active hover:text-tint-strong',
98-
journey?.label &&
99-
journey.label === selected?.label &&
100-
'border-tint bg-tint-active text-tint-strong'
101-
)}
102-
style={{
103-
animationDelay: `${i * -0.2}s`,
104-
}}
105-
onClick={() => setSelected(journey)}
106-
>
107-
{journey?.icon ? (
108-
<Icon
109-
icon={journey.icon as IconName}
110-
className="size-4 text-tint-subtle"
111-
/>
112-
) : null}
113-
{journey?.label}
114-
</button>
115-
))}
116-
</div>
117-
{selected && (
118-
<div className="mt-6 animate-present text-sm [animation-duration:1000ms]">
119-
<h3 className="font-bold text-base">
120-
{selected.icon ? (
121-
<Icon
122-
icon={selected.icon as IconName}
123-
className="mr-2 inline size-5 text-tint-subtle"
124-
/>
125-
) : null}
126-
{selected.label}
127-
</h3>
128-
<ol className="mt-2 ml-2 flex flex-col gap-2 border-tint-subtle border-l pl-5">
129-
{selected.pages?.map((page, index) => (
130-
<li
131-
key={selected.label + page.id}
132-
className="animate-fadeIn [animation-duration:500ms]"
133-
style={{ animationDelay: `${index * 0.1}s` }}
134-
>
135-
<Link href={page.href} className="flex gap-2">
136-
<Icon icon={page.icon as IconName} className="size-4" />
137-
{page.title}
138-
</Link>
139-
</li>
140-
))}
141-
</ol>
142-
</div>
11+
<AnimatePresence initial={false}>
12+
{open && (
13+
<motion.div
14+
key="page-journey-suggestions"
15+
initial={{ opacity: 0, height: 0 }}
16+
animate={{ opacity: 1, height: 'auto' }}
17+
exit={{ opacity: 0, height: 0 }}
18+
>
19+
<div className="mb-2 flex flex-row items-center gap-1 font-semibold text-tint text-xs uppercase tracking-wide">
20+
More to explore
21+
</div>
22+
<div className="grid grid-cols-2 gap-2">
23+
{journeys.map((journey, i) => {
24+
const isSelected =
25+
journey?.label && journey.label === selectedJourney?.label;
26+
const isLoading = journey?.label === undefined;
27+
return (
28+
<button
29+
type="button"
30+
key={i}
31+
disabled={journey?.label === undefined}
32+
className={tcls(
33+
'flex flex-col items-center justify-center gap-2 rounded bg-tint px-2 py-4 text-center ring-1 ring-tint-subtle ring-inset transition-all',
34+
isLoading
35+
? 'h-24 scale-90 animate-pulse'
36+
: 'hover:bg-tint-hover hover:text-tint-strong hover:ring-tint',
37+
isSelected &&
38+
'bg-primary-active text-primary-strong ring-2 ring-primary hover:bg-primary-active hover:ring-primary'
39+
)}
40+
style={{
41+
animationDelay: `${i * 0.2}s`,
42+
}}
43+
onClick={() =>
44+
setSelectedJourney(isSelected ? undefined : journey)
45+
}
46+
>
47+
{journey?.icon ? (
48+
<Icon
49+
icon={journey.icon as IconName}
50+
className="size-4 animate-fadeIn text-tint-subtle [animation-delay:300ms]"
51+
/>
52+
) : null}
53+
{journey?.label ? (
54+
<span className="animate-fadeIn [animation-delay:400ms]">
55+
{journey.label}
56+
</span>
57+
) : null}
58+
</button>
59+
);
60+
})}
61+
</div>
62+
</motion.div>
14363
)}
144-
</div>
64+
</AnimatePresence>
14565
);
14666
}

0 commit comments

Comments
 (0)