diff --git a/bun.lock b/bun.lock
index 69360cb..fdd0b00 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,12 +6,14 @@
"dependencies": {
"@hey-api/client-fetch": "^0.8.1",
"@radix-ui/react-checkbox": "^1.1.3",
+ "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.5",
"@tailwindcss/postcss": "^4.0.3",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
+ "cmdk": "^1.0.4",
"drizzle-orm": "^0.39.3",
"jose": "^5.9.6",
"lucide-react": "^0.475.0",
@@ -460,6 +462,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+ "cmdk": ["cmdk@1.0.4", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.0", "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-AnsjfHyHpQ/EFeAnG216WY7A5LiYCoZzCSygiLvfXC3H3LFGCprErteUcszaVluGOhuOTbJS3jWHrSDYPBBygg=="],
+
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -1032,6 +1036,8 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
+ "use-sync-external-store": ["use-sync-external-store@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw=="],
+
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"webpack-bundle-analyzer": ["webpack-bundle-analyzer@4.10.1", "", { "dependencies": { "@discoveryjs/json-ext": "0.5.7", "acorn": "^8.0.4", "acorn-walk": "^8.0.0", "commander": "^7.2.0", "debounce": "^1.2.1", "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", "is-plain-object": "^5.0.0", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", "ws": "^7.3.1" }, "bin": { "webpack-bundle-analyzer": "lib/bin/analyzer.js" } }, "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ=="],
diff --git a/next.config.ts b/next.config.ts
index 50cd485..4f16aa3 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -13,6 +13,14 @@ const nextConfig: NextConfig = {
turbo: {},
nodeMiddleware: true,
},
+ images: {
+ remotePatterns: [
+ {
+ protocol: 'https',
+ hostname: 'image.tmdb.org',
+ }
+ ]
+ }
}
export default process.env.BUNDLE_ANALYZER_ENABLED === 'true' ? withBundleAnalyzer(nextConfig) : nextConfig
diff --git a/package.json b/package.json
index 02aa86d..610c737 100644
--- a/package.json
+++ b/package.json
@@ -18,12 +18,14 @@
"dependencies": {
"@hey-api/client-fetch": "^0.8.1",
"@radix-ui/react-checkbox": "^1.1.3",
+ "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.5",
"@tailwindcss/postcss": "^4.0.3",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
+ "cmdk": "^1.0.4",
"drizzle-orm": "^0.39.3",
"jose": "^5.9.6",
"lucide-react": "^0.475.0",
diff --git a/src/components/main-nav.tsx b/src/components/main-nav.tsx
index 9be761a..8108d7a 100644
--- a/src/components/main-nav.tsx
+++ b/src/components/main-nav.tsx
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation'
import { navConfig, siteConfig } from '@/config/site'
import { cn } from '@/lib/utils'
+import { SearchDialog } from './search-dialog'
export function MainNav() {
const pathname = usePathname()
@@ -38,6 +39,9 @@ export function MainNav() {
)
)}
+
+
+
>
)
diff --git a/src/components/search-dialog.tsx b/src/components/search-dialog.tsx
new file mode 100644
index 0000000..77a6679
--- /dev/null
+++ b/src/components/search-dialog.tsx
@@ -0,0 +1,307 @@
+"use client"
+
+import React from 'react'
+import { useRouter } from 'next/navigation'
+import { Film, Search } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import { useDebounce } from '@/hooks/use-debounce'
+import Image from 'next/image'
+
+interface SearchResult {
+ title: string
+ media_type: string
+ path: string
+ excerpt: string
+ type: 'component' | 'media' | 'action'
+ posterPath?: string
+}
+
+const componentRegistry = [
+ {
+ id: 'settings-about',
+ title: 'About Settings',
+ content:
+ 'View information about Riven, including version numbers, support links, and contributors.',
+ path: '/settings/about'
+ },
+ {
+ id: 'settings-content',
+ title: 'Content Settings',
+ content: 'Configure content providers for Riven.',
+ path: '/settings/content'
+ },
+ {
+ id: 'settings-general',
+ title: 'General Settings',
+ content: 'Configure global and default settings for Riven.',
+ path: '/settings/general'
+ },
+ {
+ id: 'settings-mediaserver',
+ title: 'Media Server Settings',
+ content: 'Configure media server settings for Riven.',
+ path: '/settings/mediaserver'
+ },
+ {
+ id: 'settings-ranking',
+ title: 'Ranking Settings',
+ content: 'Configure ranking settings for Riven, including profiles and custom ranks.',
+ path: '/settings/ranking'
+ },
+ {
+ id: 'settings-scrapers',
+ title: 'Scraper Settings',
+ content: 'Configure scraper settings for Riven.',
+ path: '/settings/scrapers'
+ },
+ {
+ id: 'settings-layout',
+ title: 'Settings Layout',
+ content: 'Navigate between different settings pages in Riven.',
+ path: '/settings'
+ },
+ {
+ id: 'backend-version',
+ title: 'Backend Version',
+ content: 'View and check for updates to the Riven backend.',
+ path: '/settings/about#backend-version'
+ },
+ {
+ id: 'frontend-version',
+ title: 'Frontend Version',
+ content: 'View and check for updates to the Riven frontend.',
+ path: '/settings/about#frontend-version'
+ },
+ {
+ id: 'rclone-path',
+ title: 'Rclone Path',
+ content: 'View the configured Rclone path for Riven.',
+ path: '/settings/about#rclone-path'
+ },
+ {
+ id: 'library-path',
+ title: 'Library Path',
+ content: 'View the configured library path for Riven.',
+ path: '/settings/about#library-path'
+ },
+ {
+ id: 'support-discord',
+ title: 'Discord Support',
+ content: 'Get support for Riven through the Discord community.',
+ path: '/settings/about#discord'
+ },
+ {
+ id: 'support-github',
+ title: 'GitHub Support',
+ content: 'Report issues or contribute to Riven on GitHub.',
+ path: '/settings/about#github'
+ },
+ {
+ id: 'contributors',
+ title: 'Contributors',
+ content: 'View the contributors to the Riven project.',
+ path: '/settings/about#contributors'
+ },
+ {
+ id: 'ranking-profiles',
+ title: 'Ranking Profiles',
+ content:
+ 'Learn about the different ranking profiles available in Riven: default, best, and custom.',
+ path: '/settings/ranking#profiles'
+ },
+ {
+ id: 'ranking-wiki',
+ title: 'Ranking Wiki',
+ content: 'Access detailed information about Riven ranking settings.',
+ path: '/settings/ranking#wiki'
+ }
+]
+
+const mapMediaType = (mediaType: string) => {
+ switch (mediaType) {
+ case 'tv':
+ return 'Show'
+ case 'movie':
+ return 'Movie'
+ case 'person':
+ return 'Person'
+ default:
+ return
+ }
+}
+
+export function SearchDialog() {
+ const router = useRouter()
+ const [open, setOpen] = React.useState(false)
+ const [query, setQuery] = React.useState("")
+ const debouncedQuery = useDebounce(query, 300)
+ const [results, setResults] = React.useState([])
+ const [loading, setLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault()
+ setOpen((open) => !open)
+ }
+ }
+ document.addEventListener('keydown', down)
+
+ return () => document.removeEventListener('keydown', down)
+ }, [])
+
+ const searchContent = React.useCallback((query: string): SearchResult[] => {
+ const lowercaseQuery = query.toLowerCase()
+
+ const results: SearchResult[] = [
+ // {
+ // title: `Search as media: "${query}"`,
+ // media_type: '',
+ // path: '#',
+ // excerpt: `Search for "${query}" in TMDB`,
+ // type: 'action'
+ // }
+ ]
+
+ const componentResults = componentRegistry
+ .filter(component =>
+ component.id.toLowerCase().includes(lowercaseQuery) ||
+ component.title.toLowerCase().includes(lowercaseQuery) ||
+ component.content.toLowerCase().includes(lowercaseQuery)
+ )
+ .map(result => ({
+ title: result.title,
+ media_type: '',
+ path: result.path,
+ excerpt: result.content.substring(0, 100) + '...',
+ type: 'component' as const
+ }))
+
+ return [...results, ...componentResults]
+ }, [])
+
+ React.useEffect(() => {
+ if (!debouncedQuery) {
+ setResults([])
+
+return
+ }
+
+ const fetchResults = async () => {
+ setLoading(true)
+ try {
+ // First get local component results
+ const componentResults = searchContent(debouncedQuery)
+ setResults(componentResults)
+
+ // Then fetch media results
+ const response = await fetch(`/api/search/multi?query=${encodeURIComponent(debouncedQuery)}`)
+ if (!response.ok) throw new Error('Search failed')
+
+ const data = await response.json()
+ console.log(`Got search results for "${debouncedQuery}":`, data)
+ const mediaResults = data.results.map((item: any) => ({
+ title: item.title || item.name || '',
+ media_type: mapMediaType(item.media_type),
+ path: `/${item.media_type}/${item.id}`,
+ excerpt: item.overview
+ ? item.overview.substring(0, 100) + '...'
+ : 'No description available',
+ type: 'media' as const,
+ posterPath: item.poster_path
+ ? `https://image.tmdb.org/t/p/w92${item.poster_path}`
+ : undefined
+ }))
+
+ console.log(`Set search results for "${debouncedQuery}":`, [...componentResults, ...mediaResults])
+
+ setResults([...componentResults, ...mediaResults])
+ } catch (error) {
+ console.error('Search error:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchResults()
+ }, [debouncedQuery, searchContent])
+
+ const handleSelect = React.useCallback((result: SearchResult) => {
+ setOpen(false)
+ if (result.type === 'action') {
+ // Handle action type if needed
+ return
+ }
+ router.push(result.path)
+ }, [router])
+
+ return (
+ <>
+
+
+
+
+ No results found.
+ {results.length > 0 && (
+
+ {results.map((result) => (
+ handleSelect(result)}
+ className="flex items-start gap-2 p-2"
+ >
+ {result.type === 'media' && result.posterPath ? (
+
+
+
+ ) : result.type === 'media' ? (
+
+
+
+ ) : null}
+
+ {result.title}
+
+ {result.excerpt}
+
+ {result.media_type && (
+
+ {result.media_type}
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+ >
+ )
+}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
new file mode 100644
index 0000000..314fbf6
--- /dev/null
+++ b/src/components/ui/command.tsx
@@ -0,0 +1,153 @@
+"use client"
+
+import * as React from "react"
+import { type DialogProps } from "@radix-ui/react-dialog"
+import { Command as CommandPrimitive } from "cmdk"
+import { Search } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Dialog, DialogContent } from "@/components/ui/dialog"
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Command.displayName = CommandPrimitive.displayName
+
+const CommandDialog = ({ children, ...props }: DialogProps) => {
+ return (
+
+ )
+}
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+
+CommandInput.displayName = CommandPrimitive.Input.displayName
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandList.displayName = CommandPrimitive.List.displayName
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+))
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandItem.displayName = CommandPrimitive.Item.displayName
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+CommandShortcut.displayName = "CommandShortcut"
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..01ff19c
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts
new file mode 100644
index 0000000..264aec5
--- /dev/null
+++ b/src/hooks/use-debounce.ts
@@ -0,0 +1,51 @@
+import React from "react"
+
+export function useDebounce(value: T, delay?: number): T {
+ const [debouncedValue, setDebouncedValue] = React.useState(value)
+
+ React.useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay || 500)
+
+ return () => clearTimeout(timer)
+ }, [value, delay])
+
+ return debouncedValue
+ }
+
+
+// export function useDebounce(value: T, delay: number): T {
+// const [debouncedValue, setDebouncedValue] = React.useState(value)
+// const timeoutRef = React.useRef(undefined)
+// const previousValue = React.useRef(value)
+
+// React.useEffect(() => {
+// // Only update if the value has changed
+// if (value !== previousValue.current) {
+// console.log('Value changed:', { previous: previousValue.current, current: value })
+
+// // Clear any existing timeout
+// if (timeoutRef.current) {
+// console.log('Clearing previous timeout')
+// clearTimeout(timeoutRef.current)
+// }
+
+// console.log(`Setting new timeout for ${delay}ms`)
+// timeoutRef.current = setTimeout(() => {
+// console.log('Timeout executed, updating debounced value:', value)
+// setDebouncedValue(value)
+// }, delay)
+
+// previousValue.current = value
+// }
+
+// // Cleanup on unmount or delay change
+// return () => {
+// if (timeoutRef.current) {
+// console.log('Cleaning up timeout on unmount/update')
+// clearTimeout(timeoutRef.current)
+// }
+// }
+// }, [value, delay])
+
+// return debouncedValue
+// }