Spaces:
Running
Running
Commit
·
27bba9c
1
Parent(s):
d322f96
playable I think
Browse files- Dockerfile +2 -0
- api.py +9 -0
- package-lock.json +0 -0
- package.json +4 -0
- src/components/game-component.tsx +29 -54
- src/components/play-tab.tsx +21 -18
- src/components/sign-in-with-hf-button.tsx +2 -2
- src/components/ui/command.tsx +175 -0
- src/components/ui/dialog.tsx +135 -0
- src/components/ui/popover.tsx +46 -0
- src/components/ui/virtualized-combobox.tsx +240 -0
- src/lib/constants.ts +3 -0
- yarn.lock +0 -0
Dockerfile
CHANGED
@@ -42,6 +42,8 @@ RUN curl -L https://huggingface.co/HuggingFaceTB/simplewiki-pruned-text-350k/res
|
|
42 |
|
43 |
ENV WIKISPEEDIA_DB_PATH=/home/user/app/wikihop.db
|
44 |
|
|
|
|
|
45 |
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
|
46 |
|
47 |
|
|
|
42 |
|
43 |
ENV WIKISPEEDIA_DB_PATH=/home/user/app/wikihop.db
|
44 |
|
45 |
+
ENV VITE_API_BASE=""
|
46 |
+
|
47 |
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
|
48 |
|
49 |
|
api.py
CHANGED
@@ -56,6 +56,10 @@ class SQLiteDB:
|
|
56 |
links = json.loads(article["links_json"])
|
57 |
return article["title"], links
|
58 |
|
|
|
|
|
|
|
|
|
59 |
# Initialize database connection
|
60 |
db = SQLiteDB(
|
61 |
os.getenv("WIKISPEEDIA_DB_PATH", "/Users/jts/daily/wikihop/db/data/wikihop.db")
|
@@ -69,6 +73,11 @@ async def health_check():
|
|
69 |
article_count=db._article_count
|
70 |
)
|
71 |
|
|
|
|
|
|
|
|
|
|
|
72 |
@app.get("/get_article_with_links/{article_title}", response_model=ArticleResponse)
|
73 |
async def get_article(article_title: str):
|
74 |
"""Get article and its links by title"""
|
|
|
56 |
links = json.loads(article["links_json"])
|
57 |
return article["title"], links
|
58 |
|
59 |
+
def get_all_articles(self):
|
60 |
+
self.cursor.execute("SELECT title FROM core_articles")
|
61 |
+
return [row[0] for row in self.cursor.fetchall()]
|
62 |
+
|
63 |
# Initialize database connection
|
64 |
db = SQLiteDB(
|
65 |
os.getenv("WIKISPEEDIA_DB_PATH", "/Users/jts/daily/wikihop/db/data/wikihop.db")
|
|
|
73 |
article_count=db._article_count
|
74 |
)
|
75 |
|
76 |
+
@app.get("/get_all_articles", response_model=List[str])
|
77 |
+
async def get_all_articles():
|
78 |
+
"""Get all articles"""
|
79 |
+
return db.get_all_articles()
|
80 |
+
|
81 |
@app.get("/get_article_with_links/{article_title}", response_model=ArticleResponse)
|
82 |
async def get_article(article_title: str):
|
83 |
"""Get article and its links by title"""
|
package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
package.json
CHANGED
@@ -13,13 +13,17 @@
|
|
13 |
"dependencies": {
|
14 |
"@huggingface/hub": "^1.1.2",
|
15 |
"@huggingface/inference": "^3.10.0",
|
|
|
16 |
"@radix-ui/react-label": "^2.1.4",
|
|
|
17 |
"@radix-ui/react-select": "^2.2.2",
|
18 |
"@radix-ui/react-slot": "^1.2.0",
|
19 |
"@radix-ui/react-tabs": "^1.1.9",
|
20 |
"@tailwindcss/vite": "^4.1.5",
|
|
|
21 |
"class-variance-authority": "^0.7.1",
|
22 |
"clsx": "^2.1.1",
|
|
|
23 |
"d3": "^7.9.0",
|
24 |
"jwt-decode": "^4.0.0",
|
25 |
"lucide-react": "^0.503.0",
|
|
|
13 |
"dependencies": {
|
14 |
"@huggingface/hub": "^1.1.2",
|
15 |
"@huggingface/inference": "^3.10.0",
|
16 |
+
"@radix-ui/react-dialog": "^1.1.11",
|
17 |
"@radix-ui/react-label": "^2.1.4",
|
18 |
+
"@radix-ui/react-popover": "^1.1.11",
|
19 |
"@radix-ui/react-select": "^2.2.2",
|
20 |
"@radix-ui/react-slot": "^1.2.0",
|
21 |
"@radix-ui/react-tabs": "^1.1.9",
|
22 |
"@tailwindcss/vite": "^4.1.5",
|
23 |
+
"@tanstack/react-virtual": "^3.13.6",
|
24 |
"class-variance-authority": "^0.7.1",
|
25 |
"clsx": "^2.1.1",
|
26 |
+
"cmdk": "^1.1.1",
|
27 |
"d3": "^7.9.0",
|
28 |
"jwt-decode": "^4.0.0",
|
29 |
"lucide-react": "^0.503.0",
|
src/components/game-component.tsx
CHANGED
@@ -7,35 +7,8 @@ import { Flag, Clock, Hash, BarChart, ArrowRight, Bot } from "lucide-react";
|
|
7 |
import inference from "@/lib/inference";
|
8 |
import ReasoningTrace, { Run, Step } from "./reasoning-trace";
|
9 |
import ForceDirectedGraph from "./force-directed-graph";
|
|
|
10 |
|
11 |
-
const mockRun: Run = {
|
12 |
-
steps: [
|
13 |
-
{
|
14 |
-
type: "start",
|
15 |
-
article: "Dogs",
|
16 |
-
metadata: {
|
17 |
-
message: "Starting Node",
|
18 |
-
},
|
19 |
-
},
|
20 |
-
{
|
21 |
-
type: "step",
|
22 |
-
article: "Dogs",
|
23 |
-
links: ["Dogs", "Cats", "Birds"],
|
24 |
-
metadata: {
|
25 |
-
conversation: [
|
26 |
-
{
|
27 |
-
role: "user",
|
28 |
-
content: "I want to go to the moon",
|
29 |
-
},
|
30 |
-
{
|
31 |
-
role: "assistant",
|
32 |
-
content: "I want to go to the moon",
|
33 |
-
},
|
34 |
-
],
|
35 |
-
},
|
36 |
-
},
|
37 |
-
],
|
38 |
-
};
|
39 |
const buildPrompt = (
|
40 |
current: string,
|
41 |
target: string,
|
@@ -65,8 +38,6 @@ First, analyze each link briefly and how it connects to your goal, then select t
|
|
65 |
Remember to format your final answer by explicitly writing out the xml number tags like this: <answer>NUMBER</answer>`;
|
66 |
};
|
67 |
|
68 |
-
const API_BASE = "";
|
69 |
-
|
70 |
interface GameComponentProps {
|
71 |
player: "me" | "model";
|
72 |
model?: string;
|
@@ -97,6 +68,7 @@ export default function GameComponent({
|
|
97 |
const [gameStatus, setGameStatus] = useState<"playing" | "won" | "lost">(
|
98 |
"playing"
|
99 |
);
|
|
|
100 |
const [reasoningTrace, setReasoningTrace] = useState<Run | null>({
|
101 |
start_article: startPage,
|
102 |
destination_article: targetPage,
|
@@ -151,8 +123,17 @@ export default function GameComponent({
|
|
151 |
|
152 |
const addStepToReasoningTrace = (step: Step) => {
|
153 |
setReasoningTrace((prev) => {
|
154 |
-
if (!prev)
|
155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
156 |
});
|
157 |
};
|
158 |
|
@@ -316,23 +297,6 @@ export default function GameComponent({
|
|
316 |
)}
|
317 |
</div>
|
318 |
|
319 |
-
{/* Wikipedia iframe (mocked) */}
|
320 |
-
{/* <div className="bg-muted/30 rounded-md flex-1 mb-4 overflow-hidden">
|
321 |
-
<div className="bg-white p-4 border-b">
|
322 |
-
<h2 className="text-xl font-bold">{currentPage}</h2>
|
323 |
-
<p className="text-sm text-muted-foreground">
|
324 |
-
https://en.wikipedia.org/wiki/{currentPage.replace(/\s+/g, "_")}
|
325 |
-
</p>
|
326 |
-
</div>
|
327 |
-
<div className="p-4">
|
328 |
-
<p className="text-sm">
|
329 |
-
This is a mock Wikipedia page for {currentPage}. In the actual
|
330 |
-
implementation, this would be an iframe showing the real Wikipedia
|
331 |
-
page.
|
332 |
-
</p>
|
333 |
-
</div>
|
334 |
-
</div> */}
|
335 |
-
|
336 |
{/* Available links */}
|
337 |
{gameStatus === "playing" && (
|
338 |
<>
|
@@ -403,22 +367,33 @@ export default function GameComponent({
|
|
403 |
</div>
|
404 |
)}
|
405 |
</Card>
|
406 |
-
|
407 |
<Card className="p-4 flex flex-col max-h-[500px] overflow-y-auto">
|
408 |
<ReasoningTrace run={reasoningTrace} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
409 |
</Card>
|
410 |
|
411 |
{/* Right pane - Game stats and graph */}
|
412 |
-
<Card className="p-4 flex flex-col">
|
413 |
<div className="flex-1 bg-muted/30 rounded-md p-4">
|
414 |
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground mb-2">
|
415 |
<BarChart className="h-4 w-4" /> Path Visualization
|
416 |
</div>
|
417 |
|
418 |
-
<
|
|
|
|
|
419 |
</div>
|
420 |
-
</Card>
|
421 |
</div>
|
422 |
);
|
423 |
}
|
424 |
-
|
|
|
7 |
import inference from "@/lib/inference";
|
8 |
import ReasoningTrace, { Run, Step } from "./reasoning-trace";
|
9 |
import ForceDirectedGraph from "./force-directed-graph";
|
10 |
+
import { API_BASE } from "@/lib/constants";
|
11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
const buildPrompt = (
|
13 |
current: string,
|
14 |
target: string,
|
|
|
38 |
Remember to format your final answer by explicitly writing out the xml number tags like this: <answer>NUMBER</answer>`;
|
39 |
};
|
40 |
|
|
|
|
|
41 |
interface GameComponentProps {
|
42 |
player: "me" | "model";
|
43 |
model?: string;
|
|
|
68 |
const [gameStatus, setGameStatus] = useState<"playing" | "won" | "lost">(
|
69 |
"playing"
|
70 |
);
|
71 |
+
|
72 |
const [reasoningTrace, setReasoningTrace] = useState<Run | null>({
|
73 |
start_article: startPage,
|
74 |
destination_article: targetPage,
|
|
|
123 |
|
124 |
const addStepToReasoningTrace = (step: Step) => {
|
125 |
setReasoningTrace((prev) => {
|
126 |
+
if (!prev)
|
127 |
+
return {
|
128 |
+
steps: [step],
|
129 |
+
start_article: startPage,
|
130 |
+
destination_article: targetPage,
|
131 |
+
};
|
132 |
+
return {
|
133 |
+
steps: [...prev.steps, step],
|
134 |
+
start_article: startPage,
|
135 |
+
destination_article: targetPage,
|
136 |
+
};
|
137 |
});
|
138 |
};
|
139 |
|
|
|
297 |
)}
|
298 |
</div>
|
299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
300 |
{/* Available links */}
|
301 |
{gameStatus === "playing" && (
|
302 |
<>
|
|
|
367 |
</div>
|
368 |
)}
|
369 |
</Card>
|
370 |
+
{/*
|
371 |
<Card className="p-4 flex flex-col max-h-[500px] overflow-y-auto">
|
372 |
<ReasoningTrace run={reasoningTrace} />
|
373 |
+
</Card> */}
|
374 |
+
|
375 |
+
<Card className="p-4 flex flex-col max-h-[500px] overflow-y-auto">
|
376 |
+
<iframe
|
377 |
+
src={`https://simple.wikipedia.org/wiki/${currentPage.replace(
|
378 |
+
/\s+/g,
|
379 |
+
"_"
|
380 |
+
)}`}
|
381 |
+
className="w-full h-full"
|
382 |
+
/>
|
383 |
</Card>
|
384 |
|
385 |
{/* Right pane - Game stats and graph */}
|
386 |
+
{/* <Card className="p-4 flex flex-col overflow-y-auto">
|
387 |
<div className="flex-1 bg-muted/30 rounded-md p-4">
|
388 |
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground mb-2">
|
389 |
<BarChart className="h-4 w-4" /> Path Visualization
|
390 |
</div>
|
391 |
|
392 |
+
<div className="h-[500px]">
|
393 |
+
<ForceDirectedGraph runs={runs} runId={0} />
|
394 |
+
</div>
|
395 |
</div>
|
396 |
+
</Card> */}
|
397 |
</div>
|
398 |
);
|
399 |
}
|
|
src/components/play-tab.tsx
CHANGED
@@ -6,8 +6,6 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
6 |
import { Input } from "@/components/ui/input";
|
7 |
import { Label } from "@/components/ui/label";
|
8 |
import { Button } from "@/components/ui/button";
|
9 |
-
import { Sun } from "lucide-react";
|
10 |
-
import { Wifi, WifiOff } from "lucide-react";
|
11 |
import GameComponent from "@/components/game-component";
|
12 |
import {
|
13 |
Select,
|
@@ -18,8 +16,9 @@ import {
|
|
18 |
SelectTrigger,
|
19 |
SelectValue,
|
20 |
} from "@/components/ui/select";
|
|
|
|
|
21 |
|
22 |
-
const API_BASE = "";
|
23 |
|
24 |
export default function PlayTab() {
|
25 |
const [player, setPlayer] = useState<"me" | "model">("me");
|
@@ -33,7 +32,7 @@ export default function PlayTab() {
|
|
33 |
const [maxLinks, setMaxLinks] = useState<number>(200);
|
34 |
const [isServerConnected, setIsServerConnected] = useState<boolean>(false);
|
35 |
const [modelList, setModelList] = useState<{id: string, name: string, author: string, likes: number, trendingScore: number}[]>([]);
|
36 |
-
|
37 |
// Server connection check
|
38 |
useEffect(() => {
|
39 |
fetchAvailableModels();
|
@@ -53,6 +52,15 @@ export default function PlayTab() {
|
|
53 |
return () => clearInterval(interval);
|
54 |
}, []);
|
55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
56 |
const handleStartGame = () => {
|
57 |
setIsGameStarted(true);
|
58 |
};
|
@@ -86,8 +94,6 @@ export default function PlayTab() {
|
|
86 |
<div className="space-y-4">
|
87 |
{!isGameStarted ? (
|
88 |
<Card className="p-6">
|
89 |
-
|
90 |
-
|
91 |
<div className="grid grid-cols-2 md:grid-cols-3 gap-6 mt-6">
|
92 |
<div>
|
93 |
<Label htmlFor="player-select" className="block mb-2">
|
@@ -109,27 +115,24 @@ export default function PlayTab() {
|
|
109 |
<Label htmlFor="start-page" className="block mb-2">
|
110 |
Start Page
|
111 |
</Label>
|
112 |
-
<
|
113 |
-
|
114 |
value={startPage}
|
115 |
-
|
116 |
-
|
117 |
/>
|
118 |
</div>
|
119 |
|
120 |
<div className="flex items-end gap-2">
|
121 |
<div className="flex-1">
|
122 |
-
<Label
|
123 |
-
htmlFor="target-page"
|
124 |
-
className="flex items-center gap-1 mb-2"
|
125 |
-
>
|
126 |
Target Page
|
127 |
</Label>
|
128 |
-
<
|
129 |
-
|
130 |
value={targetPage}
|
131 |
-
|
132 |
-
|
133 |
/>
|
134 |
</div>
|
135 |
<Button onClick={handleStartGame} className="mb-0.5">
|
|
|
6 |
import { Input } from "@/components/ui/input";
|
7 |
import { Label } from "@/components/ui/label";
|
8 |
import { Button } from "@/components/ui/button";
|
|
|
|
|
9 |
import GameComponent from "@/components/game-component";
|
10 |
import {
|
11 |
Select,
|
|
|
16 |
SelectTrigger,
|
17 |
SelectValue,
|
18 |
} from "@/components/ui/select";
|
19 |
+
import { API_BASE } from "@/lib/constants";
|
20 |
+
import { VirtualizedCombobox } from "./ui/virtualized-combobox";
|
21 |
|
|
|
22 |
|
23 |
export default function PlayTab() {
|
24 |
const [player, setPlayer] = useState<"me" | "model">("me");
|
|
|
32 |
const [maxLinks, setMaxLinks] = useState<number>(200);
|
33 |
const [isServerConnected, setIsServerConnected] = useState<boolean>(false);
|
34 |
const [modelList, setModelList] = useState<{id: string, name: string, author: string, likes: number, trendingScore: number}[]>([]);
|
35 |
+
const [allArticles, setAllArticles] = useState<string[]>([]);
|
36 |
// Server connection check
|
37 |
useEffect(() => {
|
38 |
fetchAvailableModels();
|
|
|
52 |
return () => clearInterval(interval);
|
53 |
}, []);
|
54 |
|
55 |
+
useEffect(() => {
|
56 |
+
const fetchAllArticles = async () => {
|
57 |
+
const response = await fetch(`${API_BASE}/get_all_articles`);
|
58 |
+
const data = await response.json();
|
59 |
+
setAllArticles(data);
|
60 |
+
};
|
61 |
+
fetchAllArticles();
|
62 |
+
}, []);
|
63 |
+
|
64 |
const handleStartGame = () => {
|
65 |
setIsGameStarted(true);
|
66 |
};
|
|
|
94 |
<div className="space-y-4">
|
95 |
{!isGameStarted ? (
|
96 |
<Card className="p-6">
|
|
|
|
|
97 |
<div className="grid grid-cols-2 md:grid-cols-3 gap-6 mt-6">
|
98 |
<div>
|
99 |
<Label htmlFor="player-select" className="block mb-2">
|
|
|
115 |
<Label htmlFor="start-page" className="block mb-2">
|
116 |
Start Page
|
117 |
</Label>
|
118 |
+
<VirtualizedCombobox
|
119 |
+
options={allArticles}
|
120 |
value={startPage}
|
121 |
+
onValueChange={(value) => setStartPage(value)}
|
122 |
+
searchPlaceholder="e.g. Dogs"
|
123 |
/>
|
124 |
</div>
|
125 |
|
126 |
<div className="flex items-end gap-2">
|
127 |
<div className="flex-1">
|
128 |
+
<Label htmlFor="start-page" className="block mb-2">
|
|
|
|
|
|
|
129 |
Target Page
|
130 |
</Label>
|
131 |
+
<VirtualizedCombobox
|
132 |
+
options={allArticles}
|
133 |
value={targetPage}
|
134 |
+
onValueChange={(value) => setTargetPage(value)}
|
135 |
+
searchPlaceholder="e.g. Canada"
|
136 |
/>
|
137 |
</div>
|
138 |
<Button onClick={handleStartGame} className="mb-0.5">
|
src/components/sign-in-with-hf-button.tsx
CHANGED
@@ -8,7 +8,7 @@ const REDIRECT_URI = "https://huggingfacetb-wikispeedia.hf.space";
|
|
8 |
const SCOPE = "openid%20profile%20email%20inference-api";
|
9 |
const STATE = "1234567890";
|
10 |
const SSO_URL = `https://huggingface.co/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=${SCOPE}&prompt=consent&state=${STATE}`;
|
11 |
-
const
|
12 |
const CLIENT_SECRET = import.meta.env.VITE_HUGGINGFACE_CLIENT_SECRET; // THIS IS UNSAFE, must fix before real deploy
|
13 |
|
14 |
export const SignInWithHuggingFaceButton = () => {
|
@@ -37,7 +37,7 @@ export const SignInWithHuggingFaceButton = () => {
|
|
37 |
// remove the code from the url
|
38 |
window.history.replaceState({}, "", window.location.pathname);
|
39 |
setIsLoading(true);
|
40 |
-
const response = await fetch(`${
|
41 |
method: "POST",
|
42 |
headers: {
|
43 |
"Content-Type": "application/x-www-form-urlencoded",
|
|
|
8 |
const SCOPE = "openid%20profile%20email%20inference-api";
|
9 |
const STATE = "1234567890";
|
10 |
const SSO_URL = `https://huggingface.co/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=${SCOPE}&prompt=consent&state=${STATE}`;
|
11 |
+
const OAUTH_API_BASE = "https://huggingface.co/oauth/token";
|
12 |
const CLIENT_SECRET = import.meta.env.VITE_HUGGINGFACE_CLIENT_SECRET; // THIS IS UNSAFE, must fix before real deploy
|
13 |
|
14 |
export const SignInWithHuggingFaceButton = () => {
|
|
|
37 |
// remove the code from the url
|
38 |
window.history.replaceState({}, "", window.location.pathname);
|
39 |
setIsLoading(true);
|
40 |
+
const response = await fetch(`${OAUTH_API_BASE}`, {
|
41 |
method: "POST",
|
42 |
headers: {
|
43 |
"Content-Type": "application/x-www-form-urlencoded",
|
src/components/ui/command.tsx
ADDED
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import { Command as CommandPrimitive } from "cmdk"
|
3 |
+
import { SearchIcon } from "lucide-react"
|
4 |
+
|
5 |
+
import { cn } from "@/lib/utils"
|
6 |
+
import {
|
7 |
+
Dialog,
|
8 |
+
DialogContent,
|
9 |
+
DialogDescription,
|
10 |
+
DialogHeader,
|
11 |
+
DialogTitle,
|
12 |
+
} from "@/components/ui/dialog"
|
13 |
+
|
14 |
+
function Command({
|
15 |
+
className,
|
16 |
+
...props
|
17 |
+
}: React.ComponentProps<typeof CommandPrimitive>) {
|
18 |
+
return (
|
19 |
+
<CommandPrimitive
|
20 |
+
data-slot="command"
|
21 |
+
className={cn(
|
22 |
+
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
23 |
+
className
|
24 |
+
)}
|
25 |
+
{...props}
|
26 |
+
/>
|
27 |
+
)
|
28 |
+
}
|
29 |
+
|
30 |
+
function CommandDialog({
|
31 |
+
title = "Command Palette",
|
32 |
+
description = "Search for a command to run...",
|
33 |
+
children,
|
34 |
+
...props
|
35 |
+
}: React.ComponentProps<typeof Dialog> & {
|
36 |
+
title?: string
|
37 |
+
description?: string
|
38 |
+
}) {
|
39 |
+
return (
|
40 |
+
<Dialog {...props}>
|
41 |
+
<DialogHeader className="sr-only">
|
42 |
+
<DialogTitle>{title}</DialogTitle>
|
43 |
+
<DialogDescription>{description}</DialogDescription>
|
44 |
+
</DialogHeader>
|
45 |
+
<DialogContent className="overflow-hidden p-0">
|
46 |
+
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
47 |
+
{children}
|
48 |
+
</Command>
|
49 |
+
</DialogContent>
|
50 |
+
</Dialog>
|
51 |
+
)
|
52 |
+
}
|
53 |
+
|
54 |
+
function CommandInput({
|
55 |
+
className,
|
56 |
+
...props
|
57 |
+
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
58 |
+
return (
|
59 |
+
<div
|
60 |
+
data-slot="command-input-wrapper"
|
61 |
+
className="flex h-9 items-center gap-2 border-b px-3"
|
62 |
+
>
|
63 |
+
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
64 |
+
<CommandPrimitive.Input
|
65 |
+
data-slot="command-input"
|
66 |
+
className={cn(
|
67 |
+
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
68 |
+
className
|
69 |
+
)}
|
70 |
+
{...props}
|
71 |
+
/>
|
72 |
+
</div>
|
73 |
+
)
|
74 |
+
}
|
75 |
+
|
76 |
+
function CommandList({
|
77 |
+
className,
|
78 |
+
...props
|
79 |
+
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
80 |
+
return (
|
81 |
+
<CommandPrimitive.List
|
82 |
+
data-slot="command-list"
|
83 |
+
className={cn(
|
84 |
+
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
85 |
+
className
|
86 |
+
)}
|
87 |
+
{...props}
|
88 |
+
/>
|
89 |
+
)
|
90 |
+
}
|
91 |
+
|
92 |
+
function CommandEmpty({
|
93 |
+
...props
|
94 |
+
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
95 |
+
return (
|
96 |
+
<CommandPrimitive.Empty
|
97 |
+
data-slot="command-empty"
|
98 |
+
className="py-6 text-center text-sm"
|
99 |
+
{...props}
|
100 |
+
/>
|
101 |
+
)
|
102 |
+
}
|
103 |
+
|
104 |
+
function CommandGroup({
|
105 |
+
className,
|
106 |
+
...props
|
107 |
+
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
108 |
+
return (
|
109 |
+
<CommandPrimitive.Group
|
110 |
+
data-slot="command-group"
|
111 |
+
className={cn(
|
112 |
+
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
113 |
+
className
|
114 |
+
)}
|
115 |
+
{...props}
|
116 |
+
/>
|
117 |
+
)
|
118 |
+
}
|
119 |
+
|
120 |
+
function CommandSeparator({
|
121 |
+
className,
|
122 |
+
...props
|
123 |
+
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
124 |
+
return (
|
125 |
+
<CommandPrimitive.Separator
|
126 |
+
data-slot="command-separator"
|
127 |
+
className={cn("bg-border -mx-1 h-px", className)}
|
128 |
+
{...props}
|
129 |
+
/>
|
130 |
+
)
|
131 |
+
}
|
132 |
+
|
133 |
+
function CommandItem({
|
134 |
+
className,
|
135 |
+
...props
|
136 |
+
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
137 |
+
return (
|
138 |
+
<CommandPrimitive.Item
|
139 |
+
data-slot="command-item"
|
140 |
+
className={cn(
|
141 |
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
142 |
+
className
|
143 |
+
)}
|
144 |
+
{...props}
|
145 |
+
/>
|
146 |
+
)
|
147 |
+
}
|
148 |
+
|
149 |
+
function CommandShortcut({
|
150 |
+
className,
|
151 |
+
...props
|
152 |
+
}: React.ComponentProps<"span">) {
|
153 |
+
return (
|
154 |
+
<span
|
155 |
+
data-slot="command-shortcut"
|
156 |
+
className={cn(
|
157 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
158 |
+
className
|
159 |
+
)}
|
160 |
+
{...props}
|
161 |
+
/>
|
162 |
+
)
|
163 |
+
}
|
164 |
+
|
165 |
+
export {
|
166 |
+
Command,
|
167 |
+
CommandDialog,
|
168 |
+
CommandInput,
|
169 |
+
CommandList,
|
170 |
+
CommandEmpty,
|
171 |
+
CommandGroup,
|
172 |
+
CommandItem,
|
173 |
+
CommandShortcut,
|
174 |
+
CommandSeparator,
|
175 |
+
}
|
src/components/ui/dialog.tsx
ADDED
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"use client"
|
2 |
+
|
3 |
+
import * as React from "react"
|
4 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
5 |
+
import { XIcon } from "lucide-react"
|
6 |
+
|
7 |
+
import { cn } from "@/lib/utils"
|
8 |
+
|
9 |
+
function Dialog({
|
10 |
+
...props
|
11 |
+
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
12 |
+
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
13 |
+
}
|
14 |
+
|
15 |
+
function DialogTrigger({
|
16 |
+
...props
|
17 |
+
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
18 |
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
19 |
+
}
|
20 |
+
|
21 |
+
function DialogPortal({
|
22 |
+
...props
|
23 |
+
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
24 |
+
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
25 |
+
}
|
26 |
+
|
27 |
+
function DialogClose({
|
28 |
+
...props
|
29 |
+
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
30 |
+
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
31 |
+
}
|
32 |
+
|
33 |
+
function DialogOverlay({
|
34 |
+
className,
|
35 |
+
...props
|
36 |
+
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
37 |
+
return (
|
38 |
+
<DialogPrimitive.Overlay
|
39 |
+
data-slot="dialog-overlay"
|
40 |
+
className={cn(
|
41 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
42 |
+
className
|
43 |
+
)}
|
44 |
+
{...props}
|
45 |
+
/>
|
46 |
+
)
|
47 |
+
}
|
48 |
+
|
49 |
+
function DialogContent({
|
50 |
+
className,
|
51 |
+
children,
|
52 |
+
...props
|
53 |
+
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
54 |
+
return (
|
55 |
+
<DialogPortal data-slot="dialog-portal">
|
56 |
+
<DialogOverlay />
|
57 |
+
<DialogPrimitive.Content
|
58 |
+
data-slot="dialog-content"
|
59 |
+
className={cn(
|
60 |
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
61 |
+
className
|
62 |
+
)}
|
63 |
+
{...props}
|
64 |
+
>
|
65 |
+
{children}
|
66 |
+
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
67 |
+
<XIcon />
|
68 |
+
<span className="sr-only">Close</span>
|
69 |
+
</DialogPrimitive.Close>
|
70 |
+
</DialogPrimitive.Content>
|
71 |
+
</DialogPortal>
|
72 |
+
)
|
73 |
+
}
|
74 |
+
|
75 |
+
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
76 |
+
return (
|
77 |
+
<div
|
78 |
+
data-slot="dialog-header"
|
79 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
80 |
+
{...props}
|
81 |
+
/>
|
82 |
+
)
|
83 |
+
}
|
84 |
+
|
85 |
+
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
86 |
+
return (
|
87 |
+
<div
|
88 |
+
data-slot="dialog-footer"
|
89 |
+
className={cn(
|
90 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
91 |
+
className
|
92 |
+
)}
|
93 |
+
{...props}
|
94 |
+
/>
|
95 |
+
)
|
96 |
+
}
|
97 |
+
|
98 |
+
function DialogTitle({
|
99 |
+
className,
|
100 |
+
...props
|
101 |
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
102 |
+
return (
|
103 |
+
<DialogPrimitive.Title
|
104 |
+
data-slot="dialog-title"
|
105 |
+
className={cn("text-lg leading-none font-semibold", className)}
|
106 |
+
{...props}
|
107 |
+
/>
|
108 |
+
)
|
109 |
+
}
|
110 |
+
|
111 |
+
function DialogDescription({
|
112 |
+
className,
|
113 |
+
...props
|
114 |
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
115 |
+
return (
|
116 |
+
<DialogPrimitive.Description
|
117 |
+
data-slot="dialog-description"
|
118 |
+
className={cn("text-muted-foreground text-sm", className)}
|
119 |
+
{...props}
|
120 |
+
/>
|
121 |
+
)
|
122 |
+
}
|
123 |
+
|
124 |
+
export {
|
125 |
+
Dialog,
|
126 |
+
DialogClose,
|
127 |
+
DialogContent,
|
128 |
+
DialogDescription,
|
129 |
+
DialogFooter,
|
130 |
+
DialogHeader,
|
131 |
+
DialogOverlay,
|
132 |
+
DialogPortal,
|
133 |
+
DialogTitle,
|
134 |
+
DialogTrigger,
|
135 |
+
}
|
src/components/ui/popover.tsx
ADDED
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as React from "react"
|
2 |
+
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
3 |
+
|
4 |
+
import { cn } from "@/lib/utils"
|
5 |
+
|
6 |
+
function Popover({
|
7 |
+
...props
|
8 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
9 |
+
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
10 |
+
}
|
11 |
+
|
12 |
+
function PopoverTrigger({
|
13 |
+
...props
|
14 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
15 |
+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
16 |
+
}
|
17 |
+
|
18 |
+
function PopoverContent({
|
19 |
+
className,
|
20 |
+
align = "center",
|
21 |
+
sideOffset = 4,
|
22 |
+
...props
|
23 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
24 |
+
return (
|
25 |
+
<PopoverPrimitive.Portal>
|
26 |
+
<PopoverPrimitive.Content
|
27 |
+
data-slot="popover-content"
|
28 |
+
align={align}
|
29 |
+
sideOffset={sideOffset}
|
30 |
+
className={cn(
|
31 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
32 |
+
className
|
33 |
+
)}
|
34 |
+
{...props}
|
35 |
+
/>
|
36 |
+
</PopoverPrimitive.Portal>
|
37 |
+
)
|
38 |
+
}
|
39 |
+
|
40 |
+
function PopoverAnchor({
|
41 |
+
...props
|
42 |
+
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
43 |
+
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
44 |
+
}
|
45 |
+
|
46 |
+
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
src/components/ui/virtualized-combobox.tsx
ADDED
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Button } from "@/components/ui/button";
|
2 |
+
import {
|
3 |
+
Command,
|
4 |
+
CommandEmpty,
|
5 |
+
CommandGroup,
|
6 |
+
CommandInput,
|
7 |
+
CommandItem,
|
8 |
+
CommandList,
|
9 |
+
} from "@/components/ui/command";
|
10 |
+
import {
|
11 |
+
Popover,
|
12 |
+
PopoverContent,
|
13 |
+
PopoverTrigger,
|
14 |
+
} from "@/components/ui/popover";
|
15 |
+
import { cn } from "@/lib/utils";
|
16 |
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
17 |
+
import { Check, ChevronsUpDown } from "lucide-react";
|
18 |
+
import * as React from "react";
|
19 |
+
|
20 |
+
type Option = {
|
21 |
+
value: string;
|
22 |
+
label: string;
|
23 |
+
};
|
24 |
+
|
25 |
+
interface VirtualizedCommandProps {
|
26 |
+
height: string;
|
27 |
+
options: Option[];
|
28 |
+
placeholder: string;
|
29 |
+
selectedOption: string;
|
30 |
+
onSelectOption?: (option: string) => void;
|
31 |
+
}
|
32 |
+
|
33 |
+
const VirtualizedCommand = ({
|
34 |
+
height,
|
35 |
+
options,
|
36 |
+
placeholder,
|
37 |
+
selectedOption,
|
38 |
+
onSelectOption,
|
39 |
+
}: VirtualizedCommandProps) => {
|
40 |
+
const [filteredOptions, setFilteredOptions] =
|
41 |
+
React.useState<Option[]>(options);
|
42 |
+
const [focusedIndex, setFocusedIndex] = React.useState(0);
|
43 |
+
const [isKeyboardNavActive, setIsKeyboardNavActive] = React.useState(false);
|
44 |
+
|
45 |
+
const parentRef = React.useRef(null);
|
46 |
+
|
47 |
+
const virtualizer = useVirtualizer({
|
48 |
+
count: filteredOptions.length,
|
49 |
+
getScrollElement: () => parentRef.current,
|
50 |
+
estimateSize: () => 35,
|
51 |
+
});
|
52 |
+
|
53 |
+
const virtualOptions = virtualizer.getVirtualItems();
|
54 |
+
|
55 |
+
const scrollToIndex = (index: number) => {
|
56 |
+
virtualizer.scrollToIndex(index, {
|
57 |
+
align: "center",
|
58 |
+
});
|
59 |
+
};
|
60 |
+
|
61 |
+
const handleSearch = (search: string) => {
|
62 |
+
setIsKeyboardNavActive(false);
|
63 |
+
setFilteredOptions(
|
64 |
+
options.filter((option) =>
|
65 |
+
option.value.toLowerCase().includes(search.toLowerCase() ?? [])
|
66 |
+
)
|
67 |
+
);
|
68 |
+
};
|
69 |
+
|
70 |
+
const handleKeyDown = (event: React.KeyboardEvent) => {
|
71 |
+
switch (event.key) {
|
72 |
+
case "ArrowDown": {
|
73 |
+
event.preventDefault();
|
74 |
+
setIsKeyboardNavActive(true);
|
75 |
+
setFocusedIndex((prev) => {
|
76 |
+
const newIndex =
|
77 |
+
prev === -1 ? 0 : Math.min(prev + 1, filteredOptions.length - 1);
|
78 |
+
scrollToIndex(newIndex);
|
79 |
+
return newIndex;
|
80 |
+
});
|
81 |
+
break;
|
82 |
+
}
|
83 |
+
case "ArrowUp": {
|
84 |
+
event.preventDefault();
|
85 |
+
setIsKeyboardNavActive(true);
|
86 |
+
setFocusedIndex((prev) => {
|
87 |
+
const newIndex =
|
88 |
+
prev === -1 ? filteredOptions.length - 1 : Math.max(prev - 1, 0);
|
89 |
+
scrollToIndex(newIndex);
|
90 |
+
return newIndex;
|
91 |
+
});
|
92 |
+
break;
|
93 |
+
}
|
94 |
+
case "Enter": {
|
95 |
+
event.preventDefault();
|
96 |
+
if (filteredOptions[focusedIndex]) {
|
97 |
+
onSelectOption?.(filteredOptions[focusedIndex].value);
|
98 |
+
}
|
99 |
+
break;
|
100 |
+
}
|
101 |
+
default:
|
102 |
+
break;
|
103 |
+
}
|
104 |
+
};
|
105 |
+
|
106 |
+
React.useEffect(() => {
|
107 |
+
if (selectedOption) {
|
108 |
+
const option = filteredOptions.find(
|
109 |
+
(option) => option.value === selectedOption
|
110 |
+
);
|
111 |
+
if (option) {
|
112 |
+
const index = filteredOptions.indexOf(option);
|
113 |
+
setFocusedIndex(index);
|
114 |
+
virtualizer.scrollToIndex(index, {
|
115 |
+
align: "center",
|
116 |
+
});
|
117 |
+
}
|
118 |
+
}
|
119 |
+
}, [selectedOption, filteredOptions, virtualizer]);
|
120 |
+
|
121 |
+
return (
|
122 |
+
<Command shouldFilter={false} onKeyDown={handleKeyDown}>
|
123 |
+
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
|
124 |
+
<CommandList
|
125 |
+
ref={parentRef}
|
126 |
+
style={{
|
127 |
+
height: height,
|
128 |
+
width: "100%",
|
129 |
+
overflow: "auto",
|
130 |
+
}}
|
131 |
+
onMouseDown={() => setIsKeyboardNavActive(false)}
|
132 |
+
onMouseMove={() => setIsKeyboardNavActive(false)}
|
133 |
+
>
|
134 |
+
<CommandEmpty>No item found.</CommandEmpty>
|
135 |
+
<CommandGroup>
|
136 |
+
<div
|
137 |
+
style={{
|
138 |
+
height: `${virtualizer.getTotalSize()}px`,
|
139 |
+
width: "100%",
|
140 |
+
position: "relative",
|
141 |
+
}}
|
142 |
+
>
|
143 |
+
{virtualOptions.map((virtualOption) => (
|
144 |
+
<CommandItem
|
145 |
+
key={filteredOptions[virtualOption.index].value}
|
146 |
+
disabled={isKeyboardNavActive}
|
147 |
+
className={cn(
|
148 |
+
"absolute left-0 top-0 w-full bg-transparent",
|
149 |
+
focusedIndex === virtualOption.index &&
|
150 |
+
"bg-accent text-accent-foreground",
|
151 |
+
isKeyboardNavActive &&
|
152 |
+
focusedIndex !== virtualOption.index &&
|
153 |
+
"aria-selected:bg-transparent aria-selected:text-primary"
|
154 |
+
)}
|
155 |
+
style={{
|
156 |
+
height: `${virtualOption.size}px`,
|
157 |
+
transform: `translateY(${virtualOption.start}px)`,
|
158 |
+
}}
|
159 |
+
value={filteredOptions[virtualOption.index].value}
|
160 |
+
onMouseEnter={() =>
|
161 |
+
!isKeyboardNavActive && setFocusedIndex(virtualOption.index)
|
162 |
+
}
|
163 |
+
onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)}
|
164 |
+
onSelect={onSelectOption}
|
165 |
+
>
|
166 |
+
<Check
|
167 |
+
className={cn(
|
168 |
+
"mr-2 h-4 w-4",
|
169 |
+
selectedOption ===
|
170 |
+
filteredOptions[virtualOption.index].value
|
171 |
+
? "opacity-100"
|
172 |
+
: "opacity-0"
|
173 |
+
)}
|
174 |
+
/>
|
175 |
+
{filteredOptions[virtualOption.index].label}
|
176 |
+
</CommandItem>
|
177 |
+
))}
|
178 |
+
</div>
|
179 |
+
</CommandGroup>
|
180 |
+
</CommandList>
|
181 |
+
</Command>
|
182 |
+
);
|
183 |
+
};
|
184 |
+
|
185 |
+
interface VirtualizedComboboxProps {
|
186 |
+
options: string[];
|
187 |
+
searchPlaceholder?: string;
|
188 |
+
width?: string;
|
189 |
+
height?: string;
|
190 |
+
value: string;
|
191 |
+
onValueChange: (value: string) => void;
|
192 |
+
}
|
193 |
+
|
194 |
+
export function VirtualizedCombobox({
|
195 |
+
options,
|
196 |
+
searchPlaceholder = "Search items...",
|
197 |
+
value,
|
198 |
+
onValueChange,
|
199 |
+
width = "400px",
|
200 |
+
height = "400px",
|
201 |
+
}: VirtualizedComboboxProps) {
|
202 |
+
const [open, setOpen] = React.useState(false);
|
203 |
+
const [selectedOption, setSelectedOption] = React.useState("");
|
204 |
+
|
205 |
+
return (
|
206 |
+
<Popover open={open} onOpenChange={setOpen}>
|
207 |
+
<PopoverTrigger asChild>
|
208 |
+
<Button
|
209 |
+
variant="outline"
|
210 |
+
role="combobox"
|
211 |
+
aria-expanded={open}
|
212 |
+
className="justify-between"
|
213 |
+
style={{
|
214 |
+
width: width,
|
215 |
+
}}
|
216 |
+
>
|
217 |
+
{selectedOption
|
218 |
+
? options.find((option) => option === selectedOption)
|
219 |
+
: searchPlaceholder}
|
220 |
+
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
221 |
+
</Button>
|
222 |
+
</PopoverTrigger>
|
223 |
+
<PopoverContent className="p-0" style={{ width: width }}>
|
224 |
+
<VirtualizedCommand
|
225 |
+
height={height}
|
226 |
+
options={options.map((option) => ({ value: option, label: option }))}
|
227 |
+
placeholder={searchPlaceholder}
|
228 |
+
selectedOption={value}
|
229 |
+
onSelectOption={(currentValue) => {
|
230 |
+
setSelectedOption(
|
231 |
+
currentValue === selectedOption ? "" : currentValue
|
232 |
+
);
|
233 |
+
onValueChange(currentValue);
|
234 |
+
setOpen(false);
|
235 |
+
}}
|
236 |
+
/>
|
237 |
+
</PopoverContent>
|
238 |
+
</Popover>
|
239 |
+
);
|
240 |
+
}
|
src/lib/constants.ts
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
export const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
2 |
+
|
3 |
+
console.log("API_BASE", API_BASE);
|
yarn.lock
CHANGED
The diff for this file is too large to render.
See raw diff
|
|