|
1 | 1 | 'use client';
|
2 | 2 | import { tcls } from '@/lib/tailwind';
|
3 | 3 | 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'; |
10 | 6 |
|
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(); |
84 | 9 |
|
85 | 10 | 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> |
143 | 63 | )}
|
144 |
| - </div> |
| 64 | + </AnimatePresence> |
145 | 65 | );
|
146 | 66 | }
|
0 commit comments