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.title} +
+ ) : 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 ( + + + + {children} + + + + ) +} + +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 +// }