Spaces:
Sleeping
Sleeping
Julian Bilcke commited on
Commit ·
b8c4528
1
Parent(s): e3cc490
working on AI Tube search engine
Browse files- .env +0 -2
- package-lock.json +39 -0
- package.json +6 -1
- src/app/interface/search-input/index.tsx +144 -0
- src/app/interface/top-header/index.tsx +23 -4
- src/app/server/actions/ai-tube-hf/getVideos.ts +41 -4
- src/app/server/actions/ai-tube-robot/README.md +0 -3
- src/app/server/actions/ai-tube-robot/updateQueue.ts +0 -42
- src/app/server/actions/config.ts +0 -2
- src/app/server/actions/redis.ts +9 -0
- src/app/state/useStore.ts +41 -1
- src/components/ui/popover.tsx +1 -1
.env
CHANGED
|
@@ -8,8 +8,6 @@ NEXT_PUBLIC_AI_TUBE_OAUTH_CLIENT_ID="35c3efbc-d51f-4763-b5ea-3e149c6158e5"
|
|
| 8 |
ADMIN_HUGGING_FACE_API_TOKEN=""
|
| 9 |
ADMIN_HUGGING_FACE_USERNAME=""
|
| 10 |
|
| 11 |
-
AI_TUBE_ROBOT_API="https://jbilcke-hf-ai-tube-robot.hf.space"
|
| 12 |
-
|
| 13 |
UPSTASH_REDIS_REST_URL=""
|
| 14 |
UPSTASH_REDIS_REST_TOKEN=""
|
| 15 |
|
|
|
|
| 8 |
ADMIN_HUGGING_FACE_API_TOKEN=""
|
| 9 |
ADMIN_HUGGING_FACE_USERNAME=""
|
| 10 |
|
|
|
|
|
|
|
| 11 |
UPSTASH_REDIS_REST_URL=""
|
| 12 |
UPSTASH_REDIS_REST_TOKEN=""
|
| 13 |
|
package-lock.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
| 10 |
"dependencies": {
|
| 11 |
"@huggingface/hub": "0.12.3-oauth",
|
| 12 |
"@huggingface/inference": "^2.6.4",
|
|
|
|
| 13 |
"@photo-sphere-viewer/core": "^5.5.1",
|
| 14 |
"@photo-sphere-viewer/video-plugin": "^5.5.1",
|
| 15 |
"@radix-ui/react-accordion": "^1.1.2",
|
|
@@ -30,6 +31,7 @@
|
|
| 30 |
"@radix-ui/react-toast": "^1.1.4",
|
| 31 |
"@radix-ui/react-tooltip": "^1.0.6",
|
| 32 |
"@react-spring/web": "^9.7.3",
|
|
|
|
| 33 |
"@types/node": "20.4.2",
|
| 34 |
"@types/react": "18.2.15",
|
| 35 |
"@types/react-dom": "18.2.7",
|
|
@@ -45,9 +47,12 @@
|
|
| 45 |
"date-fns": "^2.30.0",
|
| 46 |
"eslint": "8.45.0",
|
| 47 |
"eslint-config-next": "13.4.10",
|
|
|
|
| 48 |
"hash-wasm": "^4.11.0",
|
|
|
|
| 49 |
"lucide-react": "^0.260.0",
|
| 50 |
"markdown-yaml-metadata-parser": "^3.0.0",
|
|
|
|
| 51 |
"next": "^14.1.0",
|
| 52 |
"photo-sphere-viewer-lensflare-plugin": "^2.0.1",
|
| 53 |
"pick": "^0.0.1",
|
|
@@ -982,6 +987,14 @@
|
|
| 982 |
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
| 983 |
}
|
| 984 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 985 |
"node_modules/@jridgewell/gen-mapping": {
|
| 986 |
"version": "0.3.3",
|
| 987 |
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
|
@@ -2602,6 +2615,19 @@
|
|
| 2602 |
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
| 2603 |
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
|
| 2604 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2605 |
"node_modules/@types/node": {
|
| 2606 |
"version": "20.4.2",
|
| 2607 |
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz",
|
|
@@ -4860,6 +4886,14 @@
|
|
| 4860 |
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
| 4861 |
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
|
| 4862 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4863 |
"node_modules/fastparse": {
|
| 4864 |
"version": "1.1.2",
|
| 4865 |
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
|
|
@@ -6087,6 +6121,11 @@
|
|
| 6087 |
"node": ">=16 || 14 >=14.17"
|
| 6088 |
}
|
| 6089 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6090 |
"node_modules/mkdirp-classic": {
|
| 6091 |
"version": "0.5.3",
|
| 6092 |
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
|
|
|
| 10 |
"dependencies": {
|
| 11 |
"@huggingface/hub": "0.12.3-oauth",
|
| 12 |
"@huggingface/inference": "^2.6.4",
|
| 13 |
+
"@jcoreio/async-throttle": "^1.6.0",
|
| 14 |
"@photo-sphere-viewer/core": "^5.5.1",
|
| 15 |
"@photo-sphere-viewer/video-plugin": "^5.5.1",
|
| 16 |
"@radix-ui/react-accordion": "^1.1.2",
|
|
|
|
| 31 |
"@radix-ui/react-toast": "^1.1.4",
|
| 32 |
"@radix-ui/react-tooltip": "^1.0.6",
|
| 33 |
"@react-spring/web": "^9.7.3",
|
| 34 |
+
"@types/lodash.debounce": "^4.0.9",
|
| 35 |
"@types/node": "20.4.2",
|
| 36 |
"@types/react": "18.2.15",
|
| 37 |
"@types/react-dom": "18.2.7",
|
|
|
|
| 47 |
"date-fns": "^2.30.0",
|
| 48 |
"eslint": "8.45.0",
|
| 49 |
"eslint-config-next": "13.4.10",
|
| 50 |
+
"fastest-levenshtein": "^1.0.16",
|
| 51 |
"hash-wasm": "^4.11.0",
|
| 52 |
+
"lodash.debounce": "^4.0.8",
|
| 53 |
"lucide-react": "^0.260.0",
|
| 54 |
"markdown-yaml-metadata-parser": "^3.0.0",
|
| 55 |
+
"minisearch": "^6.3.0",
|
| 56 |
"next": "^14.1.0",
|
| 57 |
"photo-sphere-viewer-lensflare-plugin": "^2.0.1",
|
| 58 |
"pick": "^0.0.1",
|
|
|
|
| 987 |
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
| 988 |
}
|
| 989 |
},
|
| 990 |
+
"node_modules/@jcoreio/async-throttle": {
|
| 991 |
+
"version": "1.6.0",
|
| 992 |
+
"resolved": "https://registry.npmjs.org/@jcoreio/async-throttle/-/async-throttle-1.6.0.tgz",
|
| 993 |
+
"integrity": "sha512-0efaXmn498OKPti0tG1GGCPdQwnfHecBGyJZ9eJzZf779WEDbAURGAFh4NWgbuTHU53KSMA2fwJcn6WqlOVRJA==",
|
| 994 |
+
"dependencies": {
|
| 995 |
+
"@babel/runtime": "^7.12.5"
|
| 996 |
+
}
|
| 997 |
+
},
|
| 998 |
"node_modules/@jridgewell/gen-mapping": {
|
| 999 |
"version": "0.3.3",
|
| 1000 |
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
|
|
|
| 2615 |
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
| 2616 |
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="
|
| 2617 |
},
|
| 2618 |
+
"node_modules/@types/lodash": {
|
| 2619 |
+
"version": "4.14.202",
|
| 2620 |
+
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.202.tgz",
|
| 2621 |
+
"integrity": "sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ=="
|
| 2622 |
+
},
|
| 2623 |
+
"node_modules/@types/lodash.debounce": {
|
| 2624 |
+
"version": "4.0.9",
|
| 2625 |
+
"resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz",
|
| 2626 |
+
"integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==",
|
| 2627 |
+
"dependencies": {
|
| 2628 |
+
"@types/lodash": "*"
|
| 2629 |
+
}
|
| 2630 |
+
},
|
| 2631 |
"node_modules/@types/node": {
|
| 2632 |
"version": "20.4.2",
|
| 2633 |
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz",
|
|
|
|
| 4886 |
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
|
| 4887 |
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
|
| 4888 |
},
|
| 4889 |
+
"node_modules/fastest-levenshtein": {
|
| 4890 |
+
"version": "1.0.16",
|
| 4891 |
+
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
|
| 4892 |
+
"integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
|
| 4893 |
+
"engines": {
|
| 4894 |
+
"node": ">= 4.9.1"
|
| 4895 |
+
}
|
| 4896 |
+
},
|
| 4897 |
"node_modules/fastparse": {
|
| 4898 |
"version": "1.1.2",
|
| 4899 |
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
|
|
|
|
| 6121 |
"node": ">=16 || 14 >=14.17"
|
| 6122 |
}
|
| 6123 |
},
|
| 6124 |
+
"node_modules/minisearch": {
|
| 6125 |
+
"version": "6.3.0",
|
| 6126 |
+
"resolved": "https://registry.npmjs.org/minisearch/-/minisearch-6.3.0.tgz",
|
| 6127 |
+
"integrity": "sha512-ihFnidEeU8iXzcVHy74dhkxh/dn8Dc08ERl0xwoMMGqp4+LvRSCgicb+zGqWthVokQKvCSxITlh3P08OzdTYCQ=="
|
| 6128 |
+
},
|
| 6129 |
"node_modules/mkdirp-classic": {
|
| 6130 |
"version": "0.5.3",
|
| 6131 |
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
package.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
| 11 |
"dependencies": {
|
| 12 |
"@huggingface/hub": "0.12.3-oauth",
|
| 13 |
"@huggingface/inference": "^2.6.4",
|
|
|
|
| 14 |
"@photo-sphere-viewer/core": "^5.5.1",
|
| 15 |
"@photo-sphere-viewer/video-plugin": "^5.5.1",
|
| 16 |
"@radix-ui/react-accordion": "^1.1.2",
|
|
@@ -31,12 +32,13 @@
|
|
| 31 |
"@radix-ui/react-toast": "^1.1.4",
|
| 32 |
"@radix-ui/react-tooltip": "^1.0.6",
|
| 33 |
"@react-spring/web": "^9.7.3",
|
|
|
|
| 34 |
"@types/node": "20.4.2",
|
| 35 |
"@types/react": "18.2.15",
|
| 36 |
"@types/react-dom": "18.2.7",
|
| 37 |
"@types/uuid": "^9.0.2",
|
| 38 |
-
"@upstash/redis": "^1.28.3",
|
| 39 |
"@upstash/query": "^0.0.2",
|
|
|
|
| 40 |
"alchemy-sdk": "^3.1.2",
|
| 41 |
"autoprefixer": "10.4.14",
|
| 42 |
"class-variance-authority": "^0.6.1",
|
|
@@ -46,9 +48,12 @@
|
|
| 46 |
"date-fns": "^2.30.0",
|
| 47 |
"eslint": "8.45.0",
|
| 48 |
"eslint-config-next": "13.4.10",
|
|
|
|
| 49 |
"hash-wasm": "^4.11.0",
|
|
|
|
| 50 |
"lucide-react": "^0.260.0",
|
| 51 |
"markdown-yaml-metadata-parser": "^3.0.0",
|
|
|
|
| 52 |
"next": "^14.1.0",
|
| 53 |
"photo-sphere-viewer-lensflare-plugin": "^2.0.1",
|
| 54 |
"pick": "^0.0.1",
|
|
|
|
| 11 |
"dependencies": {
|
| 12 |
"@huggingface/hub": "0.12.3-oauth",
|
| 13 |
"@huggingface/inference": "^2.6.4",
|
| 14 |
+
"@jcoreio/async-throttle": "^1.6.0",
|
| 15 |
"@photo-sphere-viewer/core": "^5.5.1",
|
| 16 |
"@photo-sphere-viewer/video-plugin": "^5.5.1",
|
| 17 |
"@radix-ui/react-accordion": "^1.1.2",
|
|
|
|
| 32 |
"@radix-ui/react-toast": "^1.1.4",
|
| 33 |
"@radix-ui/react-tooltip": "^1.0.6",
|
| 34 |
"@react-spring/web": "^9.7.3",
|
| 35 |
+
"@types/lodash.debounce": "^4.0.9",
|
| 36 |
"@types/node": "20.4.2",
|
| 37 |
"@types/react": "18.2.15",
|
| 38 |
"@types/react-dom": "18.2.7",
|
| 39 |
"@types/uuid": "^9.0.2",
|
|
|
|
| 40 |
"@upstash/query": "^0.0.2",
|
| 41 |
+
"@upstash/redis": "^1.28.3",
|
| 42 |
"alchemy-sdk": "^3.1.2",
|
| 43 |
"autoprefixer": "10.4.14",
|
| 44 |
"class-variance-authority": "^0.6.1",
|
|
|
|
| 48 |
"date-fns": "^2.30.0",
|
| 49 |
"eslint": "8.45.0",
|
| 50 |
"eslint-config-next": "13.4.10",
|
| 51 |
+
"fastest-levenshtein": "^1.0.16",
|
| 52 |
"hash-wasm": "^4.11.0",
|
| 53 |
+
"lodash.debounce": "^4.0.8",
|
| 54 |
"lucide-react": "^0.260.0",
|
| 55 |
"markdown-yaml-metadata-parser": "^3.0.0",
|
| 56 |
+
"minisearch": "^6.3.0",
|
| 57 |
"next": "^14.1.0",
|
| 58 |
"photo-sphere-viewer-lensflare-plugin": "^2.0.1",
|
| 59 |
"pick": "^0.0.1",
|
src/app/interface/search-input/index.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useTransition } from "react"
|
| 2 |
+
import Link from "next/link"
|
| 3 |
+
// import throttle from "@jcoreio/async-throttle"
|
| 4 |
+
import debounce from "lodash.debounce"
|
| 5 |
+
import { GoSearch } from "react-icons/go"
|
| 6 |
+
|
| 7 |
+
import { useStore } from "@/app/state/useStore"
|
| 8 |
+
import { cn } from "@/lib/utils"
|
| 9 |
+
import { Input } from "@/components/ui/input"
|
| 10 |
+
import { Button } from "@/components/ui/button"
|
| 11 |
+
import { getVideos } from "@/app/server/actions/ai-tube-hf/getVideos"
|
| 12 |
+
|
| 13 |
+
export function SearchInput() {
|
| 14 |
+
const [_pending, startTransition] = useTransition()
|
| 15 |
+
|
| 16 |
+
const setSearchAutocompleteQuery = useStore(s => s.setSearchAutocompleteQuery)
|
| 17 |
+
const showAutocompleteBox = useStore(s => s.showAutocompleteBox)
|
| 18 |
+
const setShowAutocompleteBox = useStore(s => s.setShowAutocompleteBox)
|
| 19 |
+
|
| 20 |
+
const searchAutocompleteResults = useStore(s => s.searchAutocompleteResults)
|
| 21 |
+
const setSearchAutocompleteResults = useStore(s => s.setSearchAutocompleteResults)
|
| 22 |
+
|
| 23 |
+
const setSearchQuery = useStore(s => s.setSearchQuery)
|
| 24 |
+
|
| 25 |
+
const [searchDraft, setSearchDraft] = useState("")
|
| 26 |
+
|
| 27 |
+
// called when pressing enter or clicking on search
|
| 28 |
+
const debouncedSearch = debounce((query: string) => {
|
| 29 |
+
startTransition(async () => {
|
| 30 |
+
console.log(`searching for "${query}"..`)
|
| 31 |
+
|
| 32 |
+
const videos = await getVideos({
|
| 33 |
+
query,
|
| 34 |
+
sortBy: "match",
|
| 35 |
+
maxVideos: 8,
|
| 36 |
+
neverThrow: true,
|
| 37 |
+
renewCache: false, // bit of optimization
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
console.log(`got ${videos.length} results!`)
|
| 41 |
+
setSearchAutocompleteResults(videos)
|
| 42 |
+
|
| 43 |
+
// TODO: only close the show autocomplete box if we found something
|
| 44 |
+
// setShowAutocompleteBox(false)
|
| 45 |
+
})
|
| 46 |
+
}, 1000)
|
| 47 |
+
|
| 48 |
+
// called when pressing enter or clicking on search
|
| 49 |
+
const handleSearch = () => {
|
| 50 |
+
setSearchQuery(searchDraft)
|
| 51 |
+
setShowAutocompleteBox(true)
|
| 52 |
+
debouncedSearch(searchDraft)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="flex flex-row flex-grow w-[380px] lg:w-[600px]">
|
| 57 |
+
|
| 58 |
+
<div className="flex flex-row w-full"
|
| 59 |
+
onClick={() => {
|
| 60 |
+
handleSearch()
|
| 61 |
+
}}>
|
| 62 |
+
<Input
|
| 63 |
+
placeholder="Search"
|
| 64 |
+
className={cn(
|
| 65 |
+
`bg-neutral-900 text-neutral-200 dark:bg-neutral-900 dark:text-neutral-200`,
|
| 66 |
+
`rounded-l-full rounded-r-none`,
|
| 67 |
+
`border-neutral-700 dark:border-neutral-700 border-r-0`,
|
| 68 |
+
|
| 69 |
+
)}
|
| 70 |
+
// disabled={atLeastOnePanelIsBusy}
|
| 71 |
+
onFocus={() => {
|
| 72 |
+
handleSearch()
|
| 73 |
+
}}
|
| 74 |
+
onBlur={() => {
|
| 75 |
+
setShowAutocompleteBox(false)
|
| 76 |
+
}}
|
| 77 |
+
onChange={(e) => {
|
| 78 |
+
setSearchDraft(e.target.value)
|
| 79 |
+
handleSearch()
|
| 80 |
+
}}
|
| 81 |
+
onKeyDown={({ key }) => {
|
| 82 |
+
if (key === 'Enter') {
|
| 83 |
+
handleSearch()
|
| 84 |
+
}
|
| 85 |
+
}}
|
| 86 |
+
value={searchDraft}
|
| 87 |
+
/>
|
| 88 |
+
<Button
|
| 89 |
+
className={cn(
|
| 90 |
+
`rounded-l-none rounded-r-full border border-neutral-700 dark:border-neutral-700`,
|
| 91 |
+
`cursor-pointer`,
|
| 92 |
+
`transition-all duration-200 ease-in-out`,
|
| 93 |
+
`text-neutral-200 dark:text-neutral-200 bg-neutral-800 dark:bg-neutral-800 hover:bg-neutral-700 disabled:bg-neutral-900`
|
| 94 |
+
)}
|
| 95 |
+
onClick={() => {
|
| 96 |
+
handleSearch()
|
| 97 |
+
// console.log("submit")
|
| 98 |
+
// setShowAutocompleteBox(false)
|
| 99 |
+
// setSearchDraft("")
|
| 100 |
+
}}
|
| 101 |
+
disabled={false}
|
| 102 |
+
>
|
| 103 |
+
<GoSearch className="w-6 h-6" />
|
| 104 |
+
</Button>
|
| 105 |
+
</div>
|
| 106 |
+
<div
|
| 107 |
+
className={cn(
|
| 108 |
+
`absolute z-50 ml-1`,
|
| 109 |
+
|
| 110 |
+
// please keep this in sync with the parent
|
| 111 |
+
`w-[320px] lg:w-[540px]`,
|
| 112 |
+
|
| 113 |
+
`text-neutral-200 dark:text-neutral-200 bg-neutral-900 dark:bg-neutral-900`,
|
| 114 |
+
`border border-neutral-800 dark:border-neutral-800`,
|
| 115 |
+
`rounded-xl shadow-2xl`,
|
| 116 |
+
`flex flex-col p-2 space-y-1`,
|
| 117 |
+
|
| 118 |
+
`transition-all duration-200 ease-in-out`,
|
| 119 |
+
showAutocompleteBox
|
| 120 |
+
? `opacity-100 scale-100 mt-11`
|
| 121 |
+
: `opacity-0 scale-95 mt-6`
|
| 122 |
+
)}
|
| 123 |
+
>
|
| 124 |
+
{searchAutocompleteResults.length === 0 ? <div>No results found.</div> : null}
|
| 125 |
+
{searchAutocompleteResults.map(media => (
|
| 126 |
+
<Link key={media.id} href={`/watch?v=${media.id}`}>
|
| 127 |
+
<div
|
| 128 |
+
className={cn(
|
| 129 |
+
`dark:hover:bg-neutral-800 hover:bg-neutral-800`,
|
| 130 |
+
`text-sm`,
|
| 131 |
+
`px-3 py-2`,
|
| 132 |
+
`rounded-xl`
|
| 133 |
+
)}
|
| 134 |
+
|
| 135 |
+
>
|
| 136 |
+
|
| 137 |
+
{media.label}
|
| 138 |
+
</div>
|
| 139 |
+
</Link>
|
| 140 |
+
))}
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
)
|
| 144 |
+
}
|
src/app/interface/top-header/index.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
-
import { useEffect, useTransition } from 'react'
|
| 2 |
|
| 3 |
import { Pathway_Gothic_One } from 'next/font/google'
|
| 4 |
import { PiPopcornBold } from "react-icons/pi"
|
|
|
|
| 5 |
|
| 6 |
const pathway = Pathway_Gothic_One({
|
| 7 |
weight: "400",
|
|
@@ -14,6 +15,9 @@ import { useStore } from "@/app/state/useStore"
|
|
| 14 |
import { cn } from "@/lib/utils"
|
| 15 |
import { getTags } from '@/app/server/actions/ai-tube-hf/getTags'
|
| 16 |
import Link from 'next/link'
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
export function TopHeader() {
|
| 19 |
const [_pending, startTransition] = useTransition()
|
|
@@ -33,6 +37,17 @@ export function TopHeader() {
|
|
| 33 |
const currentTags = useStore(s => s.currentTags)
|
| 34 |
const setCurrentTags = useStore(s => s.setCurrentTags)
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
const isNormalSize = headerMode === "normal"
|
| 37 |
|
| 38 |
|
|
@@ -118,9 +133,13 @@ export function TopHeader() {
|
|
| 118 |
`px-4 py-2 w-max-64`,
|
| 119 |
`text-neutral-400 text-2xs sm:text-xs lg:text-sm italic`
|
| 120 |
)}>
|
| 121 |
-
|
| 122 |
-
<div
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</div>
|
| 125 |
</div>
|
| 126 |
{
|
|
|
|
| 1 |
+
import { useEffect, useState, useTransition } from 'react'
|
| 2 |
|
| 3 |
import { Pathway_Gothic_One } from 'next/font/google'
|
| 4 |
import { PiPopcornBold } from "react-icons/pi"
|
| 5 |
+
import { GoSearch } from "react-icons/go"
|
| 6 |
|
| 7 |
const pathway = Pathway_Gothic_One({
|
| 8 |
weight: "400",
|
|
|
|
| 15 |
import { cn } from "@/lib/utils"
|
| 16 |
import { getTags } from '@/app/server/actions/ai-tube-hf/getTags'
|
| 17 |
import Link from 'next/link'
|
| 18 |
+
import { Input } from '@/components/ui/input'
|
| 19 |
+
import { Button } from '@/components/ui/button'
|
| 20 |
+
import { SearchInput } from '../search-input'
|
| 21 |
|
| 22 |
export function TopHeader() {
|
| 23 |
const [_pending, startTransition] = useTransition()
|
|
|
|
| 37 |
const currentTags = useStore(s => s.currentTags)
|
| 38 |
const setCurrentTags = useStore(s => s.setCurrentTags)
|
| 39 |
|
| 40 |
+
const setSearchAutocompleteQuery = useStore(s => s.setSearchAutocompleteQuery)
|
| 41 |
+
const searchAutocompleteResults = useStore(s => s.searchAutocompleteResults)
|
| 42 |
+
|
| 43 |
+
const setSearchQuery = useStore(s => s.setSearchQuery)
|
| 44 |
+
|
| 45 |
+
const [searchDraft, setSearchDraft] = useState("")
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
const searchQuery = searchDraft.trim().toLowerCase()
|
| 48 |
+
setSearchQuery(searchQuery)
|
| 49 |
+
}, [searchDraft])
|
| 50 |
+
|
| 51 |
const isNormalSize = headerMode === "normal"
|
| 52 |
|
| 53 |
|
|
|
|
| 133 |
`px-4 py-2 w-max-64`,
|
| 134 |
`text-neutral-400 text-2xs sm:text-xs lg:text-sm italic`
|
| 135 |
)}>
|
| 136 |
+
<SearchInput />
|
| 137 |
+
</div>
|
| 138 |
+
<div className={cn("w-32 xl:w-42")}>
|
| 139 |
+
<span>
|
| 140 |
+
|
| 141 |
+
{/* reserved for future use */}
|
| 142 |
+
</span>
|
| 143 |
</div>
|
| 144 |
</div>
|
| 145 |
{
|
src/app/server/actions/ai-tube-hf/getVideos.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
"use server"
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
import { VideoInfo } from "@/types/general"
|
| 4 |
|
| 5 |
import { getVideoIndex } from "./getVideoIndex"
|
|
@@ -11,13 +14,18 @@ const HARD_LIMIT = 100
|
|
| 11 |
|
| 12 |
// this just return ALL videos on the platform
|
| 13 |
export async function getVideos({
|
|
|
|
| 14 |
mandatoryTags = [],
|
| 15 |
niceToHaveTags = [],
|
| 16 |
sortBy = "date",
|
| 17 |
ignoreVideoIds = [],
|
| 18 |
maxVideos = HARD_LIMIT,
|
| 19 |
neverThrow = false,
|
|
|
|
| 20 |
}: {
|
|
|
|
|
|
|
|
|
|
| 21 |
// the videos MUST include those tags
|
| 22 |
mandatoryTags?: string[]
|
| 23 |
|
|
@@ -25,7 +33,10 @@ export async function getVideos({
|
|
| 25 |
// but it isn't a hard limit - TODO: use some semantic search here?
|
| 26 |
niceToHaveTags?: string[]
|
| 27 |
|
| 28 |
-
sortBy?:
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
// ignore some ids - this is used to not show the same videos again
|
| 31 |
// eg. videos already watched, or disliked etc
|
|
@@ -34,13 +45,15 @@ export async function getVideos({
|
|
| 34 |
maxVideos?: number
|
| 35 |
|
| 36 |
neverThrow?: boolean
|
|
|
|
|
|
|
| 37 |
}): Promise<VideoInfo[]> {
|
| 38 |
try {
|
| 39 |
// the index is gonna grow more and more,
|
| 40 |
// but in the future we will use some DB eg. Prisma or sqlite
|
| 41 |
const published = await getVideoIndex({
|
| 42 |
status: "published",
|
| 43 |
-
renewCache
|
| 44 |
})
|
| 45 |
|
| 46 |
let allPotentiallyValidVideos = Object.values(published)
|
|
@@ -53,8 +66,32 @@ export async function getVideos({
|
|
| 53 |
allPotentiallyValidVideos = allPotentiallyValidVideos.filter(video => !ignoreVideoIds.includes(video.id))
|
| 54 |
}
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
} else {
|
| 59 |
allPotentiallyValidVideos.sort(() => Math.random() - 0.5)
|
| 60 |
}
|
|
|
|
| 1 |
"use server"
|
| 2 |
|
| 3 |
+
// import { distance } from "fastest-levenshtein"
|
| 4 |
+
import MiniSearch from "minisearch"
|
| 5 |
+
|
| 6 |
import { VideoInfo } from "@/types/general"
|
| 7 |
|
| 8 |
import { getVideoIndex } from "./getVideoIndex"
|
|
|
|
| 14 |
|
| 15 |
// this just return ALL videos on the platform
|
| 16 |
export async function getVideos({
|
| 17 |
+
query = "",
|
| 18 |
mandatoryTags = [],
|
| 19 |
niceToHaveTags = [],
|
| 20 |
sortBy = "date",
|
| 21 |
ignoreVideoIds = [],
|
| 22 |
maxVideos = HARD_LIMIT,
|
| 23 |
neverThrow = false,
|
| 24 |
+
renewCache = true,
|
| 25 |
}: {
|
| 26 |
+
// optional search query
|
| 27 |
+
query?: string
|
| 28 |
+
|
| 29 |
// the videos MUST include those tags
|
| 30 |
mandatoryTags?: string[]
|
| 31 |
|
|
|
|
| 33 |
// but it isn't a hard limit - TODO: use some semantic search here?
|
| 34 |
niceToHaveTags?: string[]
|
| 35 |
|
| 36 |
+
sortBy?:
|
| 37 |
+
| "random" // for the home
|
| 38 |
+
| "date" // most recent first
|
| 39 |
+
| "match" // how close we are from the query
|
| 40 |
|
| 41 |
// ignore some ids - this is used to not show the same videos again
|
| 42 |
// eg. videos already watched, or disliked etc
|
|
|
|
| 45 |
maxVideos?: number
|
| 46 |
|
| 47 |
neverThrow?: boolean
|
| 48 |
+
|
| 49 |
+
renewCache?: boolean
|
| 50 |
}): Promise<VideoInfo[]> {
|
| 51 |
try {
|
| 52 |
// the index is gonna grow more and more,
|
| 53 |
// but in the future we will use some DB eg. Prisma or sqlite
|
| 54 |
const published = await getVideoIndex({
|
| 55 |
status: "published",
|
| 56 |
+
renewCache,
|
| 57 |
})
|
| 58 |
|
| 59 |
let allPotentiallyValidVideos = Object.values(published)
|
|
|
|
| 66 |
allPotentiallyValidVideos = allPotentiallyValidVideos.filter(video => !ignoreVideoIds.includes(video.id))
|
| 67 |
}
|
| 68 |
|
| 69 |
+
const q = query.trim().toLowerCase()
|
| 70 |
+
|
| 71 |
+
if (sortBy === "match") {
|
| 72 |
+
// now obviously we are going to migrate to a database search instead,
|
| 73 |
+
// maybe a bit of vector search too,
|
| 74 |
+
// but let's say that for now this is good enough
|
| 75 |
+
let miniSearch = new MiniSearch({
|
| 76 |
+
fields: ['label', 'description', 'tags'], // fields to index for full-text search
|
| 77 |
+
storeFields: ['id'] // fields to return with search results
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
miniSearch.addAll(allPotentiallyValidVideos)
|
| 81 |
+
|
| 82 |
+
// mini search has plenty of options, see:
|
| 83 |
+
// https://www.npmjs.com/package/minisearch
|
| 84 |
+
const results = miniSearch.search(query, {
|
| 85 |
+
prefix: true, // "moto" will match "motorcycle"
|
| 86 |
+
fuzzy: 0.2,
|
| 87 |
+
// to search within a specific category
|
| 88 |
+
// filter: (result) => result.category === 'fiction'
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
allPotentiallyValidVideos = allPotentiallyValidVideos.filter(v => results.some(r => r.id === v.id))
|
| 92 |
+
|
| 93 |
+
} if (sortBy === "date") {
|
| 94 |
+
allPotentiallyValidVideos.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
| 95 |
} else {
|
| 96 |
allPotentiallyValidVideos.sort(() => Math.random() - 0.5)
|
| 97 |
}
|
src/app/server/actions/ai-tube-robot/README.md
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
# server/actions/ai-tube-robot
|
| 2 |
-
|
| 3 |
-
API client for the AI Tube Robot
|
|
|
|
|
|
|
|
|
|
|
|
src/app/server/actions/ai-tube-robot/updateQueue.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
"use server"
|
| 2 |
-
|
| 3 |
-
import { ChannelInfo, UpdateQueueResponse } from "@/types/general"
|
| 4 |
-
|
| 5 |
-
import { aiTubeRobotApi } from "../config"
|
| 6 |
-
|
| 7 |
-
export async function updateQueue({
|
| 8 |
-
channel,
|
| 9 |
-
apiKey,
|
| 10 |
-
}: {
|
| 11 |
-
channel?: ChannelInfo
|
| 12 |
-
apiKey: string
|
| 13 |
-
}): Promise<number> {
|
| 14 |
-
if (!apiKey) {
|
| 15 |
-
throw new Error(`the apiKey is required`)
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
const res = await fetch(`${aiTubeRobotApi}/update-queue`, {
|
| 19 |
-
method: "POST",
|
| 20 |
-
headers: {
|
| 21 |
-
Accept: "application/json",
|
| 22 |
-
"Content-Type": "application/json",
|
| 23 |
-
// Authorization: `Bearer ${apiToken}`,
|
| 24 |
-
},
|
| 25 |
-
body: JSON.stringify({
|
| 26 |
-
apiKey,
|
| 27 |
-
channel
|
| 28 |
-
}),
|
| 29 |
-
cache: 'no-store',
|
| 30 |
-
// we can also use this (see https://vercel.com/blog/vercel-cache-api-nextjs-cache)
|
| 31 |
-
// next: { revalidate: 1 }
|
| 32 |
-
})
|
| 33 |
-
|
| 34 |
-
if (res.status !== 200) {
|
| 35 |
-
// This will activate the closest `error.js` Error Boundary
|
| 36 |
-
throw new Error('Failed to fetch data')
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
const response = (await res.json()) as UpdateQueueResponse
|
| 40 |
-
// console.log("response:", response)
|
| 41 |
-
return response.nbUpdated
|
| 42 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/server/actions/config.ts
CHANGED
|
@@ -6,8 +6,6 @@ export const adminUsername = `${process.env.ADMIN_HUGGING_FACE_USERNAME || ""}`
|
|
| 6 |
|
| 7 |
export const adminCredentials: Credentials = { accessToken: adminApiKey }
|
| 8 |
|
| 9 |
-
export const aiTubeRobotApi = `${process.env.AI_TUBE_ROBOT_API || ""}`
|
| 10 |
-
|
| 11 |
export const redisUrl = `${process.env.UPSTASH_REDIS_REST_URL || ""}`
|
| 12 |
export const redisToken = `${process.env.UPSTASH_REDIS_REST_TOKEN || ""}`
|
| 13 |
|
|
|
|
| 6 |
|
| 7 |
export const adminCredentials: Credentials = { accessToken: adminApiKey }
|
| 8 |
|
|
|
|
|
|
|
| 9 |
export const redisUrl = `${process.env.UPSTASH_REDIS_REST_URL || ""}`
|
| 10 |
export const redisToken = `${process.env.UPSTASH_REDIS_REST_TOKEN || ""}`
|
| 11 |
|
src/app/server/actions/redis.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { Redis } from "@upstash/redis"
|
|
|
|
| 2 |
|
| 3 |
import { redisToken, redisUrl } from "./config"
|
| 4 |
|
|
@@ -7,3 +8,11 @@ export const redis = new Redis({
|
|
| 7 |
token: redisToken
|
| 8 |
})
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { Redis } from "@upstash/redis"
|
| 2 |
+
import { Query } from "@upstash/query"
|
| 3 |
|
| 4 |
import { redisToken, redisUrl } from "./config"
|
| 5 |
|
|
|
|
| 8 |
token: redisToken
|
| 9 |
})
|
| 10 |
|
| 11 |
+
/*
|
| 12 |
+
const q = new Redis({
|
| 13 |
+
url: redisUrl,
|
| 14 |
+
token: redisToken,
|
| 15 |
+
automaticDeserialization: false, // redis query needs it to false?
|
| 16 |
+
})
|
| 17 |
+
*/
|
| 18 |
+
|
src/app/state/useStore.ts
CHANGED
|
@@ -19,6 +19,21 @@ export const useStore = create<{
|
|
| 19 |
|
| 20 |
setPathname: (pathname: string) => void
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
currentUser?: UserInfo
|
| 23 |
setCurrentUser: (currentUser?: UserInfo) => void
|
| 24 |
|
|
@@ -102,12 +117,37 @@ export const useStore = create<{
|
|
| 102 |
set({ view: routes[pathname] || "not_found" })
|
| 103 |
},
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
currentUser: undefined,
|
| 106 |
setCurrentUser: (currentUser?: UserInfo) => {
|
| 107 |
set({ currentUser })
|
| 108 |
},
|
| 109 |
|
| 110 |
-
headerMode: "normal",
|
| 111 |
setHeaderMode: (headerMode: InterfaceHeaderMode) => {
|
| 112 |
set({ headerMode })
|
| 113 |
},
|
|
|
|
| 19 |
|
| 20 |
setPathname: (pathname: string) => void
|
| 21 |
|
| 22 |
+
searchQuery: string
|
| 23 |
+
setSearchQuery: (searchQuery?: string) => void
|
| 24 |
+
|
| 25 |
+
showAutocompleteBox: boolean
|
| 26 |
+
setShowAutocompleteBox: (showAutocompleteBox: boolean) => void
|
| 27 |
+
|
| 28 |
+
searchAutocompleteQuery: string
|
| 29 |
+
setSearchAutocompleteQuery: (searchAutocompleteQuery?: string) => void
|
| 30 |
+
|
| 31 |
+
searchAutocompleteResults: VideoInfo[]
|
| 32 |
+
setSearchAutocompleteResults: (searchAutocompleteResults: VideoInfo[]) => void
|
| 33 |
+
|
| 34 |
+
searchResults: VideoInfo[]
|
| 35 |
+
setSearchResults: (searchResults: VideoInfo[]) => void
|
| 36 |
+
|
| 37 |
currentUser?: UserInfo
|
| 38 |
setCurrentUser: (currentUser?: UserInfo) => void
|
| 39 |
|
|
|
|
| 117 |
set({ view: routes[pathname] || "not_found" })
|
| 118 |
},
|
| 119 |
|
| 120 |
+
searchAutocompleteQuery: "",
|
| 121 |
+
setSearchAutocompleteQuery: (searchAutocompleteQuery?: string) => {
|
| 122 |
+
set({ searchAutocompleteQuery })
|
| 123 |
+
},
|
| 124 |
+
|
| 125 |
+
showAutocompleteBox: false,
|
| 126 |
+
setShowAutocompleteBox: (showAutocompleteBox: boolean) => {
|
| 127 |
+
set({ showAutocompleteBox })
|
| 128 |
+
},
|
| 129 |
+
|
| 130 |
+
searchAutocompleteResults: [] as VideoInfo[],
|
| 131 |
+
setSearchAutocompleteResults: (searchAutocompleteResults: VideoInfo[]) => {
|
| 132 |
+
set({ searchAutocompleteResults })
|
| 133 |
+
},
|
| 134 |
+
|
| 135 |
+
searchQuery: "",
|
| 136 |
+
setSearchQuery: (searchQuery?: string) => {
|
| 137 |
+
set({ searchQuery })
|
| 138 |
+
},
|
| 139 |
+
|
| 140 |
+
searchResults: [] as VideoInfo[],
|
| 141 |
+
setSearchResults: (searchResults: VideoInfo[]) => {
|
| 142 |
+
set({ searchResults })
|
| 143 |
+
},
|
| 144 |
+
|
| 145 |
currentUser: undefined,
|
| 146 |
setCurrentUser: (currentUser?: UserInfo) => {
|
| 147 |
set({ currentUser })
|
| 148 |
},
|
| 149 |
|
| 150 |
+
headerMode: "normal" as InterfaceHeaderMode,
|
| 151 |
setHeaderMode: (headerMode: InterfaceHeaderMode) => {
|
| 152 |
set({ headerMode })
|
| 153 |
},
|
src/components/ui/popover.tsx
CHANGED
|
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
|
| 19 |
align={align}
|
| 20 |
sideOffset={sideOffset}
|
| 21 |
className={cn(
|
| 22 |
-
"z-50 w-72 rounded-md border border-
|
| 23 |
className
|
| 24 |
)}
|
| 25 |
{...props}
|
|
|
|
| 19 |
align={align}
|
| 20 |
sideOffset={sideOffset}
|
| 21 |
className={cn(
|
| 22 |
+
"z-50 w-72 rounded-md border border-stone-200 bg-white p-4 text-stone-950 shadow-md outline-none 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 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-50",
|
| 23 |
className
|
| 24 |
)}
|
| 25 |
{...props}
|