Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add SearchDialog component #13

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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=="],
Expand Down Expand Up @@ -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=="],
Expand Down
8 changes: 8 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/components/main-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -38,6 +39,9 @@ export function MainNav() {
)
)}
</nav>
<div className='ml-4'>
<SearchDialog />
</div>
</div>
</>
)
Expand Down
307 changes: 307 additions & 0 deletions src/components/search-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<SearchResult[]>([])
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) + '...',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Use template literals instead of string concatenation.

Replace string concatenation with template literals for better readability.

-        excerpt: result.content.substring(0, 100) + '...',
+        excerpt: `${result.content.substring(0, 100)}...`,
-            ? item.overview.substring(0, 100) + '...'
+            ? `${item.overview.substring(0, 100)}...`

Also applies to: 216-216

🧰 Tools
🪛 Biome (1.9.4)

[error] 184-184: Template literals are preferred over string concatenation.

Unsafe fix: Use a template literal.

(lint/style/useTemplate)

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)}`)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Enhance URL encoding security.

While encodeURIComponent is used, consider additional validation of the query parameter.

-        const response = await fetch(`/api/search/multi?query=${encodeURIComponent(debouncedQuery)}`)
+        const sanitizedQuery = debouncedQuery.trim().replace(/[^\w\s-]/g, '')
+        const response = await fetch(`/api/search/multi?query=${encodeURIComponent(sanitizedQuery)}`)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(`/api/search/multi?query=${encodeURIComponent(debouncedQuery)}`)
const sanitizedQuery = debouncedQuery.trim().replace(/[^\w\s-]/g, '')
const response = await fetch(`/api/search/multi?query=${encodeURIComponent(sanitizedQuery)}`)

if (!response.ok) throw new Error('Search failed')

const data = await response.json()
console.log(`Got search results for "${debouncedQuery}":`, data)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove console.log statements.

Remove debug console.log statements before deploying to production.

-        console.log(`Got search results for "${debouncedQuery}":`, data)
-        console.log(`Set search results for "${debouncedQuery}":`, [...componentResults, ...mediaResults])

Also applies to: 224-224

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
}))
Comment on lines +211 to +222
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Replace any type with a proper interface.

Using any type reduces type safety. Define a proper interface for the API response.

-        const mediaResults = data.results.map((item: any) => ({
+        interface SearchApiResult {
+          id: number
+          title?: string
+          name?: string
+          media_type: string
+          overview?: string
+          poster_path?: string
+        }
+        const mediaResults = data.results.map((item: SearchApiResult) => ({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}))
interface SearchApiResult {
id: number
title?: string
name?: string
media_type: string
overview?: string
poster_path?: string
}
const mediaResults = data.results.map((item: SearchApiResult) => ({
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
}))
🧰 Tools
🪛 Biome (1.9.4)

[error] 216-216: Template literals are preferred over string concatenation.

Unsafe fix: Use a template literal.

(lint/style/useTemplate)

🪛 ESLint

[error] 211-211: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


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 (
<>
<Button
variant="ghost"
size="icon"
onClick={() => setOpen(true)}
className="w-9 px-0"
>
<Search className="h-5 w-5" />
<span className="sr-only">Search</span>
</Button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder="Search..."
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{results.length > 0 && (
<CommandGroup heading="Results">
{results.map((result) => (
<CommandItem
key={result.path + result.title}
onSelect={() => handleSelect(result)}
className="flex items-start gap-2 p-2"
>
{result.type === 'media' && result.posterPath ? (
<div className="relative h-24 w-16 rounded">
<Image
src={result.posterPath}
alt={result.title}
width={64}
height={96}
className="object-cover rounded max-w-none"
/>
</div>
) : result.type === 'media' ? (
<div className="flex h-24 w-16 items-center justify-center rounded bg-muted">
<Film className="h-8 w-8" />
</div>
) : null}
<div className="flex flex-col">
<span className="font-medium">{result.title}</span>
<span className="text-sm text-muted-foreground">
{result.excerpt}
</span>
{result.media_type && (
<span className="text-xs text-muted-foreground">
{result.media_type}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</CommandDialog>
</>
)
}
Loading